Skip to main content

running_process_core/
lib.rs

1use std::collections::VecDeque;
2use std::fs::OpenOptions;
3use std::io::Read;
4use std::io::Write;
5use std::path::PathBuf;
6use std::process::{Child, Command, Stdio};
7use std::sync::atomic::{AtomicI64, Ordering};
8use std::sync::{Arc, Condvar, Mutex};
9use std::thread;
10use std::time::{Duration, Instant};
11
12use thiserror::Error;
13
14pub mod console_detect;
15pub mod containment;
16#[cfg(feature = "originator-scan")]
17pub mod originator;
18pub mod pty;
19mod public_symbols;
20mod rust_debug;
21pub mod sanitized;
22
23pub use console_detect::{monitor_console_windows, ConsoleWindowInfo};
24pub use containment::{ContainedChild, ContainedProcessGroup, Containment, ORIGINATOR_ENV_VAR};
25#[cfg(feature = "originator-scan")]
26pub use originator::{find_processes_by_originator, OriginatorProcessInfo};
27pub use rust_debug::{render_rust_debug_traces, RustDebugScopeGuard};
28pub use sanitized::SanitizedChild;
29
30#[macro_export]
31macro_rules! rp_rust_debug_scope {
32    ($label:expr) => {
33        let _running_process_rust_debug_scope =
34            $crate::RustDebugScopeGuard::enter($label, file!(), line!());
35    };
36}
37
38const CHILD_PID_LOG_PATH_ENV: &str = "RUNNING_PROCESS_CHILD_PID_LOG_PATH";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum StreamKind {
42    Stdout,
43    Stderr,
44}
45
46impl StreamKind {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Self::Stdout => "stdout",
50            Self::Stderr => "stderr",
51        }
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct StreamEvent {
57    pub stream: StreamKind,
58    pub line: Vec<u8>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum ReadStatus<T> {
63    Line(T),
64    Timeout,
65    Eof,
66}
67
68#[derive(Debug, Error)]
69pub enum ProcessError {
70    #[error("process already started")]
71    AlreadyStarted,
72    #[error("process is not running")]
73    NotRunning,
74    #[error("process stdin is not available")]
75    StdinUnavailable,
76    #[error("failed to spawn process: {0}")]
77    Spawn(std::io::Error),
78    #[error("failed to read process output: {0}")]
79    Io(std::io::Error),
80    #[error("process timed out")]
81    Timeout,
82}
83
84#[derive(Debug, Clone)]
85pub enum CommandSpec {
86    Shell(String),
87    Argv(Vec<String>),
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum StdinMode {
92    Inherit,
93    Piped,
94    Null,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum StderrMode {
99    Stdout,
100    Pipe,
101}
102
103#[cfg(unix)]
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum UnixSignal {
106    Interrupt,
107    Terminate,
108    Kill,
109}
110
111#[derive(Debug, Clone)]
112pub struct ProcessConfig {
113    pub command: CommandSpec,
114    pub cwd: Option<PathBuf>,
115    pub env: Option<Vec<(String, String)>>,
116    pub capture: bool,
117    pub stderr_mode: StderrMode,
118    pub creationflags: Option<u32>,
119    pub create_process_group: bool,
120    pub stdin_mode: StdinMode,
121    pub nice: Option<i32>,
122    /// Optional containment policy. `None` preserves existing behaviour.
123    /// `Some(Contained)` sets `PR_SET_PDEATHSIG(SIGKILL)` on Linux and uses
124    /// the existing Job Object on Windows. `Some(Detached)` creates a new
125    /// session (`setsid`) on Unix so the child survives the parent.
126    pub containment: Option<Containment>,
127}
128
129#[derive(Default)]
130struct QueueState {
131    stdout_queue: VecDeque<Vec<u8>>,
132    stderr_queue: VecDeque<Vec<u8>>,
133    combined_queue: VecDeque<StreamEvent>,
134    stdout_history: VecDeque<Vec<u8>>,
135    stderr_history: VecDeque<Vec<u8>>,
136    combined_history: VecDeque<StreamEvent>,
137    stdout_history_bytes: usize,
138    stderr_history_bytes: usize,
139    combined_history_bytes: usize,
140    stdout_closed: bool,
141    stderr_closed: bool,
142}
143
144/// Sentinel value for returncode atomic: process has not exited yet.
145const RETURNCODE_NOT_SET: i64 = i64::MIN;
146
147struct SharedState {
148    queues: Mutex<QueueState>,
149    condvar: Condvar,
150    /// Atomic exit code. `RETURNCODE_NOT_SET` means "not exited yet".
151    /// Updated by a background waiter thread — reading is lock-free.
152    returncode: AtomicI64,
153}
154
155#[cfg(windows)]
156struct WindowsJobHandle(usize);
157
158#[cfg(windows)]
159impl Drop for WindowsJobHandle {
160    fn drop(&mut self) {
161        unsafe {
162            winapi::um::handleapi::CloseHandle(self.0 as winapi::shared::ntdef::HANDLE);
163        }
164    }
165}
166
167struct ChildState {
168    child: Child,
169    #[cfg(windows)]
170    _job: WindowsJobHandle,
171}
172
173impl SharedState {
174    fn new(capture: bool) -> Self {
175        let queues = QueueState {
176            stdout_closed: !capture,
177            stderr_closed: !capture,
178            ..QueueState::default()
179        };
180        Self {
181            queues: Mutex::new(queues),
182            condvar: Condvar::new(),
183            returncode: AtomicI64::new(RETURNCODE_NOT_SET),
184        }
185    }
186}
187
188pub struct NativeProcess {
189    config: ProcessConfig,
190    child: Arc<Mutex<Option<ChildState>>>,
191    shared: Arc<SharedState>,
192}
193
194impl NativeProcess {
195    pub fn new(config: ProcessConfig) -> Self {
196        Self {
197            shared: Arc::new(SharedState::new(config.capture)),
198            child: Arc::new(Mutex::new(None)),
199            config,
200        }
201    }
202
203    // Preserve a stable Rust frame here in release user dumps.
204    #[inline(never)]
205    pub fn start(&self) -> Result<(), ProcessError> {
206        public_symbols::rp_native_process_start_public(self)
207    }
208
209    fn start_impl(&self) -> Result<(), ProcessError> {
210        crate::rp_rust_debug_scope!("running_process_core::NativeProcess::start");
211        let mut guard = self.child.lock().expect("child mutex poisoned");
212        if guard.is_some() {
213            return Err(ProcessError::AlreadyStarted);
214        }
215
216        let mut command = self.build_command();
217        match self.config.stdin_mode {
218            StdinMode::Inherit => {}
219            StdinMode::Piped => {
220                command.stdin(Stdio::piped());
221            }
222            StdinMode::Null => {
223                command.stdin(Stdio::null());
224            }
225        }
226        if self.config.capture {
227            command.stdout(Stdio::piped());
228            command.stderr(Stdio::piped());
229        }
230
231        let mut child = command.spawn().map_err(ProcessError::Spawn)?;
232        log_spawned_child_pid(child.id()).map_err(ProcessError::Spawn)?;
233        #[cfg(windows)]
234        let job = public_symbols::rp_assign_child_to_windows_kill_on_close_job_public(&child)
235            .map_err(ProcessError::Spawn)?;
236        if self.config.capture {
237            let stdout = child.stdout.take().expect("stdout pipe missing");
238            let stderr = child.stderr.take().expect("stderr pipe missing");
239            self.spawn_reader(stdout, StreamKind::Stdout, StreamKind::Stdout);
240            self.spawn_reader(
241                stderr,
242                StreamKind::Stderr,
243                match self.config.stderr_mode {
244                    StderrMode::Stdout => StreamKind::Stdout,
245                    StderrMode::Pipe => StreamKind::Stderr,
246                },
247            );
248        }
249        *guard = Some(ChildState {
250            child,
251            #[cfg(windows)]
252            _job: job,
253        });
254        drop(guard);
255        self.spawn_exit_waiter();
256        Ok(())
257    }
258
259    /// Background thread that polls for process exit and stores the exit code
260    /// atomically. This makes `returncode` auto-update without explicit `poll()`.
261    fn spawn_exit_waiter(&self) {
262        let child = Arc::clone(&self.child);
263        let shared = Arc::clone(&self.shared);
264        thread::spawn(move || loop {
265            if shared.returncode.load(Ordering::Acquire) != RETURNCODE_NOT_SET {
266                return;
267            }
268            {
269                let mut guard = child.lock().expect("child mutex poisoned");
270                if let Some(child_state) = guard.as_mut() {
271                    match child_state.child.try_wait() {
272                        Ok(Some(status)) => {
273                            let code = exit_code(status);
274                            shared.returncode.store(code as i64, Ordering::Release);
275                            shared.condvar.notify_all();
276                            return;
277                        }
278                        Ok(None) => {}
279                        Err(_) => return,
280                    }
281                } else {
282                    return;
283                }
284            }
285            thread::sleep(Duration::from_millis(10));
286        });
287    }
288
289    pub fn write_stdin(&self, data: &[u8]) -> Result<(), ProcessError> {
290        let mut guard = self.child.lock().expect("child mutex poisoned");
291        let child = &mut guard.as_mut().ok_or(ProcessError::NotRunning)?.child;
292        let stdin = child.stdin.as_mut().ok_or(ProcessError::StdinUnavailable)?;
293        use std::io::Write;
294        stdin.write_all(data).map_err(ProcessError::Io)?;
295        stdin.flush().map_err(ProcessError::Io)?;
296        drop(child.stdin.take());
297        Ok(())
298    }
299
300    pub fn poll(&self) -> Result<Option<i32>, ProcessError> {
301        // Fast path: check atomic set by background waiter thread.
302        if let Some(code) = self.returncode() {
303            return Ok(Some(code));
304        }
305        let mut guard = self.child.lock().expect("child mutex poisoned");
306        let Some(child_state) = guard.as_mut() else {
307            return Ok(self.returncode());
308        };
309        let child = &mut child_state.child;
310        let status = child.try_wait().map_err(ProcessError::Io)?;
311        if let Some(status) = status {
312            let code = exit_code(status);
313            self.set_returncode(code);
314            return Ok(Some(code));
315        }
316        Ok(None)
317    }
318
319    // Preserve a stable Rust frame here in release user dumps.
320    #[inline(never)]
321    pub fn wait(&self, timeout: Option<Duration>) -> Result<i32, ProcessError> {
322        public_symbols::rp_native_process_wait_public(self, timeout)
323    }
324
325    fn wait_impl(&self, timeout: Option<Duration>) -> Result<i32, ProcessError> {
326        crate::rp_rust_debug_scope!("running_process_core::NativeProcess::wait");
327        if self.child.lock().expect("child mutex poisoned").is_none() {
328            return self.returncode().ok_or(ProcessError::NotRunning);
329        }
330        // Fast path: already exited.
331        if let Some(code) = self.returncode() {
332            public_symbols::rp_native_process_wait_for_capture_completion_public(self);
333            return Ok(code);
334        }
335        let start = Instant::now();
336        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
337        loop {
338            // Check returncode (set by exit-waiter thread via atomic + condvar).
339            let rc = self.shared.returncode.load(Ordering::Acquire);
340            if rc != RETURNCODE_NOT_SET {
341                drop(guard);
342                let code = rc as i32;
343                public_symbols::rp_native_process_wait_for_capture_completion_public(self);
344                return Ok(code);
345            }
346            if let Some(limit) = timeout {
347                let elapsed = start.elapsed();
348                if elapsed >= limit {
349                    return Err(ProcessError::Timeout);
350                }
351                let remaining = limit - elapsed;
352                // Wait on condvar with timeout, capped at 50ms to recheck.
353                let wait_time = remaining.min(Duration::from_millis(50));
354                guard = self
355                    .shared
356                    .condvar
357                    .wait_timeout(guard, wait_time)
358                    .expect("queue mutex poisoned")
359                    .0;
360            } else {
361                // Wait on condvar with periodic recheck.
362                guard = self
363                    .shared
364                    .condvar
365                    .wait_timeout(guard, Duration::from_millis(50))
366                    .expect("queue mutex poisoned")
367                    .0;
368            }
369        }
370    }
371
372    // Preserve a stable Rust frame here in release user dumps.
373    #[inline(never)]
374    pub fn kill(&self) -> Result<(), ProcessError> {
375        public_symbols::rp_native_process_kill_public(self)
376    }
377
378    fn kill_impl(&self) -> Result<(), ProcessError> {
379        crate::rp_rust_debug_scope!("running_process_core::NativeProcess::kill");
380        let mut guard = self.child.lock().expect("child mutex poisoned");
381        let child = &mut guard.as_mut().ok_or(ProcessError::NotRunning)?.child;
382        child.kill().map_err(ProcessError::Io)?;
383        let status = child.wait().map_err(ProcessError::Io)?;
384        self.set_returncode(exit_code(status));
385        Ok(())
386    }
387
388    pub fn terminate(&self) -> Result<(), ProcessError> {
389        self.kill()
390    }
391
392    // Preserve a stable Rust frame here in release user dumps.
393    #[inline(never)]
394    pub fn close(&self) -> Result<(), ProcessError> {
395        public_symbols::rp_native_process_close_public(self)
396    }
397
398    fn close_impl(&self) -> Result<(), ProcessError> {
399        crate::rp_rust_debug_scope!("running_process_core::NativeProcess::close");
400        if self.child.lock().expect("child mutex poisoned").is_none() {
401            return Ok(());
402        }
403        if self.poll()?.is_none() {
404            self.kill()?;
405        } else {
406            public_symbols::rp_native_process_wait_for_capture_completion_public(self);
407        }
408        Ok(())
409    }
410
411    pub fn pid(&self) -> Option<u32> {
412        self.child
413            .lock()
414            .expect("child mutex poisoned")
415            .as_ref()
416            .map(|state| state.child.id())
417    }
418
419    pub fn returncode(&self) -> Option<i32> {
420        let v = self.shared.returncode.load(Ordering::Acquire);
421        if v == RETURNCODE_NOT_SET {
422            None
423        } else {
424            Some(v as i32)
425        }
426    }
427
428    pub fn has_pending_stream(&self, stream: StreamKind) -> bool {
429        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
430            return false;
431        }
432        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
433        match stream {
434            StreamKind::Stdout => !guard.stdout_queue.is_empty(),
435            StreamKind::Stderr => !guard.stderr_queue.is_empty(),
436        }
437    }
438
439    pub fn has_pending_combined(&self) -> bool {
440        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
441        !guard.combined_queue.is_empty()
442    }
443
444    pub fn drain_stream(&self, stream: StreamKind) -> Vec<Vec<u8>> {
445        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
446            return Vec::new();
447        }
448        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
449        let queue = match stream {
450            StreamKind::Stdout => &mut guard.stdout_queue,
451            StreamKind::Stderr => &mut guard.stderr_queue,
452        };
453        queue.drain(..).collect()
454    }
455
456    pub fn drain_combined(&self) -> Vec<StreamEvent> {
457        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
458        guard.combined_queue.drain(..).collect()
459    }
460
461    pub fn read_stream(
462        &self,
463        stream: StreamKind,
464        timeout: Option<Duration>,
465    ) -> ReadStatus<Vec<u8>> {
466        let deadline = timeout.map(|limit| Instant::now() + limit);
467        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
468
469        loop {
470            if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
471                return ReadStatus::Eof;
472            }
473
474            let queue = match stream {
475                StreamKind::Stdout => &mut guard.stdout_queue,
476                StreamKind::Stderr => &mut guard.stderr_queue,
477            };
478            if let Some(line) = queue.pop_front() {
479                return ReadStatus::Line(line);
480            }
481
482            let closed = match stream {
483                StreamKind::Stdout => {
484                    if self.config.stderr_mode == StderrMode::Stdout {
485                        guard.stdout_closed && guard.stderr_closed
486                    } else {
487                        guard.stdout_closed
488                    }
489                }
490                StreamKind::Stderr => guard.stderr_closed,
491            };
492            if closed {
493                return ReadStatus::Eof;
494            }
495
496            match deadline {
497                Some(deadline) => {
498                    let now = Instant::now();
499                    if now >= deadline {
500                        return ReadStatus::Timeout;
501                    }
502                    let wait = deadline.saturating_duration_since(now);
503                    let result = self
504                        .shared
505                        .condvar
506                        .wait_timeout(guard, wait)
507                        .expect("queue mutex poisoned");
508                    guard = result.0;
509                    if result.1.timed_out() {
510                        return ReadStatus::Timeout;
511                    }
512                }
513                None => {
514                    guard = self
515                        .shared
516                        .condvar
517                        .wait(guard)
518                        .expect("queue mutex poisoned");
519                }
520            }
521        }
522    }
523
524    // Preserve a stable Rust frame here in release user dumps.
525    #[inline(never)]
526    pub fn read_combined(&self, timeout: Option<Duration>) -> ReadStatus<StreamEvent> {
527        public_symbols::rp_native_process_read_combined_public(self, timeout)
528    }
529
530    fn read_combined_impl(&self, timeout: Option<Duration>) -> ReadStatus<StreamEvent> {
531        crate::rp_rust_debug_scope!("running_process_core::NativeProcess::read_combined");
532        let deadline = timeout.map(|limit| Instant::now() + limit);
533        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
534
535        loop {
536            if let Some(event) = guard.combined_queue.pop_front() {
537                return ReadStatus::Line(event);
538            }
539            if guard.stdout_closed && guard.stderr_closed {
540                return ReadStatus::Eof;
541            }
542
543            match deadline {
544                Some(deadline) => {
545                    let now = Instant::now();
546                    if now >= deadline {
547                        return ReadStatus::Timeout;
548                    }
549                    let wait = deadline.saturating_duration_since(now);
550                    let result = self
551                        .shared
552                        .condvar
553                        .wait_timeout(guard, wait)
554                        .expect("queue mutex poisoned");
555                    guard = result.0;
556                    if result.1.timed_out() {
557                        return ReadStatus::Timeout;
558                    }
559                }
560                None => {
561                    guard = self
562                        .shared
563                        .condvar
564                        .wait(guard)
565                        .expect("queue mutex poisoned");
566                }
567            }
568        }
569    }
570
571    pub fn captured_stdout(&self) -> Vec<Vec<u8>> {
572        self.shared
573            .queues
574            .lock()
575            .expect("queue mutex poisoned")
576            .stdout_history
577            .clone()
578            .into_iter()
579            .collect()
580    }
581
582    pub fn captured_stderr(&self) -> Vec<Vec<u8>> {
583        if self.config.stderr_mode == StderrMode::Stdout {
584            return Vec::new();
585        }
586        self.shared
587            .queues
588            .lock()
589            .expect("queue mutex poisoned")
590            .stderr_history
591            .clone()
592            .into_iter()
593            .collect()
594    }
595
596    pub fn captured_combined(&self) -> Vec<StreamEvent> {
597        self.shared
598            .queues
599            .lock()
600            .expect("queue mutex poisoned")
601            .combined_history
602            .clone()
603            .into_iter()
604            .collect()
605    }
606
607    pub fn captured_stream_bytes(&self, stream: StreamKind) -> usize {
608        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
609            return 0;
610        }
611        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
612        match stream {
613            StreamKind::Stdout => guard.stdout_history_bytes,
614            StreamKind::Stderr => guard.stderr_history_bytes,
615        }
616    }
617
618    pub fn captured_combined_bytes(&self) -> usize {
619        self.shared
620            .queues
621            .lock()
622            .expect("queue mutex poisoned")
623            .combined_history_bytes
624    }
625
626    pub fn clear_captured_stream(&self, stream: StreamKind) -> usize {
627        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
628            return 0;
629        }
630        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
631        match stream {
632            StreamKind::Stdout => {
633                let released = guard.stdout_history_bytes;
634                guard.stdout_history.clear();
635                guard.stdout_history_bytes = 0;
636                released
637            }
638            StreamKind::Stderr => {
639                let released = guard.stderr_history_bytes;
640                guard.stderr_history.clear();
641                guard.stderr_history_bytes = 0;
642                released
643            }
644        }
645    }
646
647    pub fn clear_captured_combined(&self) -> usize {
648        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
649        let released = guard.combined_history_bytes;
650        guard.combined_history.clear();
651        guard.combined_history_bytes = 0;
652        released
653    }
654
655    fn build_command(&self) -> Command {
656        let mut command = match &self.config.command {
657            CommandSpec::Shell(command) => shell_command(command),
658            CommandSpec::Argv(argv) => {
659                let mut command = Command::new(&argv[0]);
660                if argv.len() > 1 {
661                    command.args(&argv[1..]);
662                }
663                command
664            }
665        };
666        if let Some(cwd) = &self.config.cwd {
667            command.current_dir(cwd);
668        }
669        if let Some(env) = &self.config.env {
670            command.env_clear();
671            command.envs(env.iter().map(|(k, v)| (k, v)));
672        }
673        #[cfg(windows)]
674        {
675            use std::os::windows::process::CommandExt;
676
677            let flags =
678                self.config.creationflags.unwrap_or(0) | windows_priority_flags(self.config.nice);
679            if flags != 0 {
680                command.creation_flags(flags);
681            }
682        }
683        #[cfg(unix)]
684        {
685            let create_process_group = self.config.create_process_group;
686            let nice = self.config.nice;
687            let containment = self.config.containment;
688
689            let needs_pre_exec = create_process_group || nice.is_some() || containment.is_some();
690
691            if needs_pre_exec {
692                use std::os::unix::process::CommandExt;
693
694                unsafe {
695                    command.pre_exec(move || {
696                        match containment {
697                            Some(Containment::Contained) => {
698                                // Place child into its own process group.
699                                if libc::setpgid(0, 0) == -1 {
700                                    return Err(std::io::Error::last_os_error());
701                                }
702                                // Linux: ask the kernel to SIGKILL us when the
703                                // parent thread dies.
704                                // CAVEAT: PR_SET_PDEATHSIG is per-thread, not
705                                // per-process. If the spawning thread exits
706                                // before the process, the child is killed early.
707                                #[cfg(target_os = "linux")]
708                                {
709                                    if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 {
710                                        return Err(std::io::Error::last_os_error());
711                                    }
712                                    if libc::getppid() == 1 {
713                                        libc::_exit(1);
714                                    }
715                                }
716                            }
717                            Some(Containment::Detached) => {
718                                // Create a new session so the child is fully
719                                // independent of the parent.
720                                if libc::setsid() == -1 {
721                                    return Err(std::io::Error::last_os_error());
722                                }
723                            }
724                            None => {
725                                if create_process_group && libc::setpgid(0, 0) == -1 {
726                                    return Err(std::io::Error::last_os_error());
727                                }
728                            }
729                        }
730                        if let Some(nice) = nice {
731                            let result = libc::setpriority(libc::PRIO_PROCESS, 0, nice);
732                            if result == -1 {
733                                return Err(std::io::Error::last_os_error());
734                            }
735                        }
736                        Ok(())
737                    });
738                }
739            }
740        }
741        command
742    }
743
744    fn spawn_reader<R>(&self, pipe: R, source_stream: StreamKind, visible_stream: StreamKind)
745    where
746        R: Read + Send + 'static,
747    {
748        let shared = Arc::clone(&self.shared);
749        thread::spawn(move || {
750            let mut reader = pipe;
751            let mut chunk = vec![0_u8; 65536];
752            let mut pending = Vec::new();
753
754            loop {
755                match reader.read(&mut chunk) {
756                    Ok(0) => break,
757                    Ok(n) => {
758                        let lines = feed_chunk(&mut pending, &chunk[..n]);
759                        emit_lines(&shared, visible_stream, lines);
760                    }
761                    Err(_) => break,
762                }
763            }
764
765            if !pending.is_empty() {
766                emit_lines(&shared, visible_stream, vec![std::mem::take(&mut pending)]);
767            }
768
769            let mut guard = shared.queues.lock().expect("queue mutex poisoned");
770            match source_stream {
771                StreamKind::Stdout => guard.stdout_closed = true,
772                StreamKind::Stderr => guard.stderr_closed = true,
773            }
774            shared.condvar.notify_all();
775        });
776    }
777
778    fn set_returncode(&self, code: i32) {
779        self.shared.returncode.store(code as i64, Ordering::Release);
780        self.shared.condvar.notify_all();
781    }
782
783    fn wait_for_capture_completion_impl(&self) {
784        crate::rp_rust_debug_scope!(
785            "running_process_core::NativeProcess::wait_for_capture_completion"
786        );
787        if !self.config.capture {
788            return;
789        }
790
791        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
792        while !(guard.stdout_closed && guard.stderr_closed) {
793            guard = self
794                .shared
795                .condvar
796                .wait(guard)
797                .expect("queue mutex poisoned");
798        }
799    }
800}
801
802#[cfg(unix)]
803pub fn unix_set_priority(pid: u32, nice: i32) -> Result<(), std::io::Error> {
804    let result = unsafe { libc::setpriority(libc::PRIO_PROCESS, pid, nice) };
805    if result == -1 {
806        return Err(std::io::Error::last_os_error());
807    }
808    Ok(())
809}
810
811#[cfg(unix)]
812pub fn unix_signal_process(pid: u32, signal: UnixSignal) -> Result<(), std::io::Error> {
813    let result = unsafe { libc::kill(pid as i32, unix_signal_raw(signal)) };
814    if result == -1 {
815        return Err(std::io::Error::last_os_error());
816    }
817    Ok(())
818}
819
820#[cfg(unix)]
821pub fn unix_signal_process_group(pid: i32, signal: UnixSignal) -> Result<(), std::io::Error> {
822    let result = unsafe { libc::killpg(pid, unix_signal_raw(signal)) };
823    if result == -1 {
824        return Err(std::io::Error::last_os_error());
825    }
826    Ok(())
827}
828
829fn log_spawned_child_pid(pid: u32) -> Result<(), std::io::Error> {
830    let Some(path) = std::env::var_os(CHILD_PID_LOG_PATH_ENV) else {
831        return Ok(());
832    };
833
834    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
835    file.write_all(format!("{pid}\n").as_bytes())?;
836    file.flush()?;
837    Ok(())
838}
839
840#[cfg(windows)]
841fn assign_child_to_windows_kill_on_close_job_impl(
842    child: &Child,
843) -> Result<WindowsJobHandle, std::io::Error> {
844    crate::rp_rust_debug_scope!("running_process_core::assign_child_to_windows_kill_on_close_job");
845    use std::mem::zeroed;
846    use std::os::windows::io::AsRawHandle;
847
848    use winapi::shared::minwindef::FALSE;
849    use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE};
850    use winapi::um::jobapi2::{
851        AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject,
852    };
853    use winapi::um::winnt::{
854        JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
855        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
856    };
857
858    let handle = child.as_raw_handle();
859    let job = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
860    if job.is_null() || job == INVALID_HANDLE_VALUE {
861        return Err(std::io::Error::last_os_error());
862    }
863
864    let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
865    info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
866    let ok = unsafe {
867        SetInformationJobObject(
868            job,
869            JobObjectExtendedLimitInformation,
870            (&mut info as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
871            std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
872        )
873    };
874    if ok == FALSE {
875        let err = std::io::Error::last_os_error();
876        unsafe { CloseHandle(job) };
877        return Err(err);
878    }
879
880    let ok = unsafe { AssignProcessToJobObject(job, handle.cast()) };
881    if ok == FALSE {
882        let err = std::io::Error::last_os_error();
883        unsafe { CloseHandle(job) };
884        return Err(err);
885    }
886
887    Ok(WindowsJobHandle(job as usize))
888}
889
890fn feed_chunk(pending: &mut Vec<u8>, chunk: &[u8]) -> Vec<Vec<u8>> {
891    let mut lines = Vec::new();
892    let mut start = 0;
893    let mut index = 0;
894
895    while index < chunk.len() {
896        if chunk[index] == b'\n' {
897            let end = if index > start && chunk[index - 1] == b'\r' {
898                index - 1
899            } else {
900                index
901            };
902            pending.extend_from_slice(&chunk[start..end]);
903            if !pending.is_empty() {
904                lines.push(std::mem::take(pending));
905            }
906            start = index + 1;
907        }
908        index += 1;
909    }
910
911    pending.extend_from_slice(&chunk[start..]);
912    lines
913}
914
915fn emit_lines(shared: &Arc<SharedState>, stream: StreamKind, lines: Vec<Vec<u8>>) {
916    if lines.is_empty() {
917        return;
918    }
919    let mut guard = shared.queues.lock().expect("queue mutex poisoned");
920    for line in lines {
921        let line_len = line.len();
922        match stream {
923            StreamKind::Stdout => {
924                guard.stdout_history_bytes += line_len;
925                guard.stdout_history.push_back(line.clone());
926                guard.stdout_queue.push_back(line.clone());
927            }
928            StreamKind::Stderr => {
929                guard.stderr_history_bytes += line_len;
930                guard.stderr_history.push_back(line.clone());
931                guard.stderr_queue.push_back(line.clone());
932            }
933        }
934        let event = StreamEvent { stream, line };
935        guard.combined_history_bytes += line_len;
936        guard.combined_history.push_back(event.clone());
937        guard.combined_queue.push_back(event);
938    }
939    shared.condvar.notify_all();
940}
941
942fn shell_command(command: &str) -> Command {
943    #[cfg(windows)]
944    {
945        use std::os::windows::process::CommandExt;
946
947        let mut cmd = Command::new("cmd");
948        cmd.raw_arg("/D /S /C \"");
949        cmd.raw_arg(command);
950        cmd.raw_arg("\"");
951        cmd
952    }
953    #[cfg(not(windows))]
954    {
955        let mut cmd = Command::new("sh");
956        cmd.arg("-lc").arg(command);
957        cmd
958    }
959}
960
961fn exit_code(status: std::process::ExitStatus) -> i32 {
962    #[cfg(unix)]
963    {
964        use std::os::unix::process::ExitStatusExt;
965        status
966            .code()
967            .unwrap_or_else(|| -status.signal().unwrap_or(1))
968    }
969    #[cfg(not(unix))]
970    {
971        status.code().unwrap_or(1)
972    }
973}
974
975#[cfg(unix)]
976fn unix_signal_raw(signal: UnixSignal) -> i32 {
977    match signal {
978        UnixSignal::Interrupt => libc::SIGINT,
979        UnixSignal::Terminate => libc::SIGTERM,
980        UnixSignal::Kill => libc::SIGKILL,
981    }
982}
983
984#[cfg(windows)]
985fn windows_priority_flags(nice: Option<i32>) -> u32 {
986    const IDLE_PRIORITY_CLASS: u32 = 0x0000_0040;
987    const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x0000_4000;
988    const ABOVE_NORMAL_PRIORITY_CLASS: u32 = 0x0000_8000;
989    const HIGH_PRIORITY_CLASS: u32 = 0x0000_0080;
990
991    match nice {
992        Some(value) if value >= 15 => IDLE_PRIORITY_CLASS,
993        Some(value) if value >= 1 => BELOW_NORMAL_PRIORITY_CLASS,
994        Some(value) if value <= -15 => HIGH_PRIORITY_CLASS,
995        Some(value) if value <= -1 => ABOVE_NORMAL_PRIORITY_CLASS,
996        _ => 0,
997    }
998}
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    // ── StreamKind tests ──
1004
1005    #[test]
1006    fn stream_kind_as_str_stdout() {
1007        assert_eq!(StreamKind::Stdout.as_str(), "stdout");
1008    }
1009
1010    #[test]
1011    fn stream_kind_as_str_stderr() {
1012        assert_eq!(StreamKind::Stderr.as_str(), "stderr");
1013    }
1014
1015    #[test]
1016    fn stream_kind_equality() {
1017        assert_eq!(StreamKind::Stdout, StreamKind::Stdout);
1018        assert_ne!(StreamKind::Stdout, StreamKind::Stderr);
1019    }
1020
1021    // ── StreamEvent tests ──
1022
1023    #[test]
1024    fn stream_event_clone() {
1025        let event = StreamEvent {
1026            stream: StreamKind::Stdout,
1027            line: b"hello".to_vec(),
1028        };
1029        let cloned = event.clone();
1030        assert_eq!(event, cloned);
1031    }
1032
1033    // ── ReadStatus tests ──
1034
1035    #[test]
1036    fn read_status_line_variant() {
1037        let status: ReadStatus<Vec<u8>> = ReadStatus::Line(b"data".to_vec());
1038        assert!(matches!(status, ReadStatus::Line(ref v) if v == b"data"));
1039    }
1040
1041    #[test]
1042    fn read_status_timeout_variant() {
1043        let status: ReadStatus<Vec<u8>> = ReadStatus::Timeout;
1044        assert!(matches!(status, ReadStatus::Timeout));
1045    }
1046
1047    #[test]
1048    fn read_status_eof_variant() {
1049        let status: ReadStatus<Vec<u8>> = ReadStatus::Eof;
1050        assert!(matches!(status, ReadStatus::Eof));
1051    }
1052
1053    // ── ProcessError tests ──
1054
1055    #[test]
1056    fn process_error_display_already_started() {
1057        assert_eq!(
1058            ProcessError::AlreadyStarted.to_string(),
1059            "process already started"
1060        );
1061    }
1062
1063    #[test]
1064    fn process_error_display_not_running() {
1065        assert_eq!(
1066            ProcessError::NotRunning.to_string(),
1067            "process is not running"
1068        );
1069    }
1070
1071    #[test]
1072    fn process_error_display_stdin_unavailable() {
1073        assert_eq!(
1074            ProcessError::StdinUnavailable.to_string(),
1075            "process stdin is not available"
1076        );
1077    }
1078
1079    #[test]
1080    fn process_error_display_timeout() {
1081        assert_eq!(ProcessError::Timeout.to_string(), "process timed out");
1082    }
1083
1084    #[test]
1085    fn process_error_display_spawn() {
1086        let err = ProcessError::Spawn(std::io::Error::new(
1087            std::io::ErrorKind::NotFound,
1088            "not found",
1089        ));
1090        assert!(err.to_string().contains("not found"));
1091    }
1092
1093    #[test]
1094    fn process_error_display_io() {
1095        let err = ProcessError::Io(std::io::Error::new(
1096            std::io::ErrorKind::BrokenPipe,
1097            "broken",
1098        ));
1099        assert!(err.to_string().contains("broken"));
1100    }
1101
1102    // ── CommandSpec tests ──
1103
1104    #[test]
1105    fn command_spec_shell_variant() {
1106        let spec = CommandSpec::Shell("echo hello".to_string());
1107        assert!(matches!(spec, CommandSpec::Shell(ref s) if s == "echo hello"));
1108    }
1109
1110    #[test]
1111    fn command_spec_argv_variant() {
1112        let spec = CommandSpec::Argv(vec!["echo".to_string(), "hello".to_string()]);
1113        assert!(matches!(spec, CommandSpec::Argv(ref v) if v.len() == 2));
1114    }
1115
1116    // ── StdinMode / StderrMode tests ──
1117
1118    #[test]
1119    fn stdin_mode_equality() {
1120        assert_eq!(StdinMode::Inherit, StdinMode::Inherit);
1121        assert_ne!(StdinMode::Piped, StdinMode::Null);
1122    }
1123
1124    #[test]
1125    fn stderr_mode_equality() {
1126        assert_eq!(StderrMode::Stdout, StderrMode::Stdout);
1127        assert_ne!(StderrMode::Stdout, StderrMode::Pipe);
1128    }
1129
1130    // ── SharedState tests ──
1131
1132    #[test]
1133    fn shared_state_new_with_capture() {
1134        let state = SharedState::new(true);
1135        let queues = state.queues.lock().unwrap();
1136        assert!(!queues.stdout_closed);
1137        assert!(!queues.stderr_closed);
1138        assert!(queues.stdout_queue.is_empty());
1139        assert!(queues.stderr_queue.is_empty());
1140    }
1141
1142    #[test]
1143    fn shared_state_new_without_capture() {
1144        let state = SharedState::new(false);
1145        let queues = state.queues.lock().unwrap();
1146        assert!(queues.stdout_closed);
1147        assert!(queues.stderr_closed);
1148    }
1149
1150    #[test]
1151    fn shared_state_returncode_initially_not_set() {
1152        let state = SharedState::new(true);
1153        let code = state.returncode.load(Ordering::Acquire);
1154        assert_eq!(code, RETURNCODE_NOT_SET);
1155    }
1156
1157    // ── feed_chunk tests ──
1158
1159    #[test]
1160    fn feed_chunk_single_line_with_newline() {
1161        let shared = Arc::new(SharedState::new(true));
1162        let mut pending = Vec::new();
1163        let lines = feed_chunk(&mut pending, b"hello\n");
1164        emit_lines(&shared, StreamKind::Stdout, lines);
1165        let queues = shared.queues.lock().unwrap();
1166        assert_eq!(queues.stdout_queue.len(), 1);
1167        assert_eq!(queues.stdout_queue[0], b"hello");
1168        assert!(pending.is_empty());
1169    }
1170
1171    #[test]
1172    fn feed_chunk_crlf_stripping() {
1173        let shared = Arc::new(SharedState::new(true));
1174        let mut pending = Vec::new();
1175        let lines = feed_chunk(&mut pending, b"hello\r\n");
1176        emit_lines(&shared, StreamKind::Stdout, lines);
1177        let queues = shared.queues.lock().unwrap();
1178        assert_eq!(queues.stdout_queue.len(), 1);
1179        assert_eq!(queues.stdout_queue[0], b"hello");
1180    }
1181
1182    #[test]
1183    fn feed_chunk_multiple_lines() {
1184        let shared = Arc::new(SharedState::new(true));
1185        let mut pending = Vec::new();
1186        let lines = feed_chunk(&mut pending, b"a\nb\nc\n");
1187        emit_lines(&shared, StreamKind::Stdout, lines);
1188        let queues = shared.queues.lock().unwrap();
1189        assert_eq!(queues.stdout_queue.len(), 3);
1190        assert_eq!(queues.stdout_queue[0], b"a");
1191        assert_eq!(queues.stdout_queue[1], b"b");
1192        assert_eq!(queues.stdout_queue[2], b"c");
1193    }
1194
1195    #[test]
1196    fn feed_chunk_no_newline_stays_pending() {
1197        let mut pending = Vec::new();
1198        let lines = feed_chunk(&mut pending, b"partial");
1199        assert!(lines.is_empty());
1200        assert_eq!(pending, b"partial");
1201    }
1202
1203    #[test]
1204    fn feed_chunk_accumulates_pending() {
1205        let shared = Arc::new(SharedState::new(true));
1206        let mut pending = Vec::new();
1207        let lines1 = feed_chunk(&mut pending, b"hel");
1208        emit_lines(&shared, StreamKind::Stdout, lines1);
1209        let lines2 = feed_chunk(&mut pending, b"lo\n");
1210        emit_lines(&shared, StreamKind::Stdout, lines2);
1211        let queues = shared.queues.lock().unwrap();
1212        assert_eq!(queues.stdout_queue.len(), 1);
1213        assert_eq!(queues.stdout_queue[0], b"hello");
1214        assert!(pending.is_empty());
1215    }
1216
1217    #[test]
1218    fn feed_chunk_empty_line_not_emitted() {
1219        let shared = Arc::new(SharedState::new(true));
1220        let mut pending = Vec::new();
1221        let lines = feed_chunk(&mut pending, b"\n");
1222        emit_lines(&shared, StreamKind::Stdout, lines);
1223        let queues = shared.queues.lock().unwrap();
1224        assert!(queues.stdout_queue.is_empty());
1225    }
1226
1227    #[test]
1228    fn feed_chunk_stderr_goes_to_stderr_queue() {
1229        let shared = Arc::new(SharedState::new(true));
1230        let mut pending = Vec::new();
1231        let lines = feed_chunk(&mut pending, b"error\n");
1232        emit_lines(&shared, StreamKind::Stderr, lines);
1233        let queues = shared.queues.lock().unwrap();
1234        assert!(queues.stdout_queue.is_empty());
1235        assert_eq!(queues.stderr_queue.len(), 1);
1236        assert_eq!(queues.stderr_queue[0], b"error");
1237    }
1238
1239    // ── emit_lines tests ──
1240
1241    #[test]
1242    fn emit_lines_updates_all_queues_and_history() {
1243        let shared = Arc::new(SharedState::new(true));
1244        emit_lines(&shared, StreamKind::Stdout, vec![b"test".to_vec()]);
1245        let queues = shared.queues.lock().unwrap();
1246        assert_eq!(queues.stdout_queue.len(), 1);
1247        assert_eq!(queues.stdout_history.len(), 1);
1248        assert_eq!(queues.stdout_history_bytes, 4);
1249        assert_eq!(queues.combined_queue.len(), 1);
1250        assert_eq!(queues.combined_history.len(), 1);
1251        assert_eq!(queues.combined_history_bytes, 4);
1252    }
1253
1254    #[test]
1255    fn emit_lines_stderr_updates_stderr_queues() {
1256        let shared = Arc::new(SharedState::new(true));
1257        emit_lines(&shared, StreamKind::Stderr, vec![b"err".to_vec()]);
1258        let queues = shared.queues.lock().unwrap();
1259        assert_eq!(queues.stderr_queue.len(), 1);
1260        assert_eq!(queues.stderr_history.len(), 1);
1261        assert_eq!(queues.stderr_history_bytes, 3);
1262        assert_eq!(queues.combined_queue.len(), 1);
1263        assert_eq!(queues.combined_history_bytes, 3);
1264    }
1265
1266    // ── NativeProcess unit tests (no process spawn) ──
1267
1268    #[test]
1269    fn native_process_returncode_none_before_start() {
1270        let process = NativeProcess::new(ProcessConfig {
1271            command: CommandSpec::Argv(vec!["echo".into()]),
1272            cwd: None,
1273            env: None,
1274            capture: false,
1275            stderr_mode: StderrMode::Stdout,
1276            creationflags: None,
1277            create_process_group: false,
1278            stdin_mode: StdinMode::Inherit,
1279            nice: None,
1280            containment: None,
1281        });
1282        assert!(process.returncode().is_none());
1283    }
1284
1285    #[test]
1286    fn native_process_pid_none_before_start() {
1287        let process = NativeProcess::new(ProcessConfig {
1288            command: CommandSpec::Argv(vec!["echo".into()]),
1289            cwd: None,
1290            env: None,
1291            capture: false,
1292            stderr_mode: StderrMode::Stdout,
1293            creationflags: None,
1294            create_process_group: false,
1295            stdin_mode: StdinMode::Inherit,
1296            nice: None,
1297            containment: None,
1298        });
1299        assert!(process.pid().is_none());
1300    }
1301
1302    #[test]
1303    fn native_process_has_pending_false_when_no_capture() {
1304        let process = NativeProcess::new(ProcessConfig {
1305            command: CommandSpec::Argv(vec!["echo".into()]),
1306            cwd: None,
1307            env: None,
1308            capture: false,
1309            stderr_mode: StderrMode::Stdout,
1310            creationflags: None,
1311            create_process_group: false,
1312            stdin_mode: StdinMode::Inherit,
1313            nice: None,
1314            containment: None,
1315        });
1316        assert!(!process.has_pending_stream(StreamKind::Stdout));
1317        assert!(!process.has_pending_combined());
1318    }
1319
1320    #[test]
1321    fn native_process_drain_empty_when_no_capture() {
1322        let process = NativeProcess::new(ProcessConfig {
1323            command: CommandSpec::Argv(vec!["echo".into()]),
1324            cwd: None,
1325            env: None,
1326            capture: false,
1327            stderr_mode: StderrMode::Stdout,
1328            creationflags: None,
1329            create_process_group: false,
1330            stdin_mode: StdinMode::Inherit,
1331            nice: None,
1332            containment: None,
1333        });
1334        assert!(process.drain_stream(StreamKind::Stdout).is_empty());
1335        assert!(process.drain_combined().is_empty());
1336    }
1337
1338    #[test]
1339    fn native_process_stderr_not_pending_when_merged() {
1340        let process = NativeProcess::new(ProcessConfig {
1341            command: CommandSpec::Argv(vec!["echo".into()]),
1342            cwd: None,
1343            env: None,
1344            capture: true,
1345            stderr_mode: StderrMode::Stdout,
1346            creationflags: None,
1347            create_process_group: false,
1348            stdin_mode: StdinMode::Inherit,
1349            nice: None,
1350            containment: None,
1351        });
1352        assert!(!process.has_pending_stream(StreamKind::Stderr));
1353    }
1354
1355    #[test]
1356    fn native_process_drain_stderr_empty_when_merged() {
1357        let process = NativeProcess::new(ProcessConfig {
1358            command: CommandSpec::Argv(vec!["echo".into()]),
1359            cwd: None,
1360            env: None,
1361            capture: true,
1362            stderr_mode: StderrMode::Stdout,
1363            creationflags: None,
1364            create_process_group: false,
1365            stdin_mode: StdinMode::Inherit,
1366            nice: None,
1367            containment: None,
1368        });
1369        assert!(process.drain_stream(StreamKind::Stderr).is_empty());
1370    }
1371
1372    #[test]
1373    fn native_process_captured_stderr_empty_when_merged() {
1374        let process = NativeProcess::new(ProcessConfig {
1375            command: CommandSpec::Argv(vec!["echo".into()]),
1376            cwd: None,
1377            env: None,
1378            capture: true,
1379            stderr_mode: StderrMode::Stdout,
1380            creationflags: None,
1381            create_process_group: false,
1382            stdin_mode: StdinMode::Inherit,
1383            nice: None,
1384            containment: None,
1385        });
1386        assert!(process.captured_stderr().is_empty());
1387    }
1388
1389    #[test]
1390    fn native_process_captured_stream_bytes_zero_when_merged_stderr() {
1391        let process = NativeProcess::new(ProcessConfig {
1392            command: CommandSpec::Argv(vec!["echo".into()]),
1393            cwd: None,
1394            env: None,
1395            capture: true,
1396            stderr_mode: StderrMode::Stdout,
1397            creationflags: None,
1398            create_process_group: false,
1399            stdin_mode: StdinMode::Inherit,
1400            nice: None,
1401            containment: None,
1402        });
1403        assert_eq!(process.captured_stream_bytes(StreamKind::Stderr), 0);
1404    }
1405
1406    #[test]
1407    fn native_process_clear_captured_stderr_zero_when_merged() {
1408        let process = NativeProcess::new(ProcessConfig {
1409            command: CommandSpec::Argv(vec!["echo".into()]),
1410            cwd: None,
1411            env: None,
1412            capture: true,
1413            stderr_mode: StderrMode::Stdout,
1414            creationflags: None,
1415            create_process_group: false,
1416            stdin_mode: StdinMode::Inherit,
1417            nice: None,
1418            containment: None,
1419        });
1420        assert_eq!(process.clear_captured_stream(StreamKind::Stderr), 0);
1421    }
1422
1423    #[test]
1424    fn native_process_read_stream_eof_when_stderr_merged() {
1425        let process = NativeProcess::new(ProcessConfig {
1426            command: CommandSpec::Argv(vec!["echo".into()]),
1427            cwd: None,
1428            env: None,
1429            capture: true,
1430            stderr_mode: StderrMode::Stdout,
1431            creationflags: None,
1432            create_process_group: false,
1433            stdin_mode: StdinMode::Inherit,
1434            nice: None,
1435            containment: None,
1436        });
1437        assert_eq!(
1438            process.read_stream(StreamKind::Stderr, Some(Duration::from_millis(10))),
1439            ReadStatus::Eof
1440        );
1441    }
1442
1443    // ── log_spawned_child_pid ──
1444
1445    #[test]
1446    fn log_spawned_child_pid_noop_without_env() {
1447        std::env::remove_var("RUNNING_PROCESS_CHILD_PID_LOG_PATH");
1448        assert!(log_spawned_child_pid(12345).is_ok());
1449    }
1450
1451    // ── shell_command ──
1452
1453    #[test]
1454    fn shell_command_creates_command() {
1455        let cmd = shell_command("echo test");
1456        let _ = format!("{:?}", cmd);
1457    }
1458
1459    // ── exit_code ──
1460
1461    #[test]
1462    fn exit_code_from_success() {
1463        let output = std::process::Command::new("python")
1464            .args(["-c", "pass"])
1465            .output()
1466            .unwrap();
1467        assert_eq!(exit_code(output.status), 0);
1468    }
1469
1470    #[test]
1471    fn exit_code_from_nonzero() {
1472        let output = std::process::Command::new("python")
1473            .args(["-c", "import sys; sys.exit(42)"])
1474            .output()
1475            .unwrap();
1476        assert_eq!(exit_code(output.status), 42);
1477    }
1478
1479    // ── windows_priority_flags ──
1480
1481    #[cfg(windows)]
1482    mod windows_tests {
1483        use super::*;
1484
1485        const IDLE_PRIORITY_CLASS: u32 = 0x0000_0040;
1486        const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x0000_4000;
1487        const ABOVE_NORMAL_PRIORITY_CLASS: u32 = 0x0000_8000;
1488        const HIGH_PRIORITY_CLASS: u32 = 0x0000_0080;
1489
1490        #[test]
1491        fn priority_flags_none() {
1492            assert_eq!(windows_priority_flags(None), 0);
1493        }
1494
1495        #[test]
1496        fn priority_flags_zero() {
1497            assert_eq!(windows_priority_flags(Some(0)), 0);
1498        }
1499
1500        #[test]
1501        fn priority_flags_high_nice_idle() {
1502            assert_eq!(windows_priority_flags(Some(15)), IDLE_PRIORITY_CLASS);
1503            assert_eq!(windows_priority_flags(Some(20)), IDLE_PRIORITY_CLASS);
1504        }
1505
1506        #[test]
1507        fn priority_flags_low_positive_below_normal() {
1508            assert_eq!(windows_priority_flags(Some(1)), BELOW_NORMAL_PRIORITY_CLASS);
1509            assert_eq!(
1510                windows_priority_flags(Some(14)),
1511                BELOW_NORMAL_PRIORITY_CLASS
1512            );
1513        }
1514
1515        #[test]
1516        fn priority_flags_negative_above_normal() {
1517            assert_eq!(
1518                windows_priority_flags(Some(-1)),
1519                ABOVE_NORMAL_PRIORITY_CLASS
1520            );
1521            assert_eq!(
1522                windows_priority_flags(Some(-14)),
1523                ABOVE_NORMAL_PRIORITY_CLASS
1524            );
1525        }
1526
1527        #[test]
1528        fn priority_flags_very_negative_high() {
1529            assert_eq!(windows_priority_flags(Some(-15)), HIGH_PRIORITY_CLASS);
1530            assert_eq!(windows_priority_flags(Some(-20)), HIGH_PRIORITY_CLASS);
1531        }
1532    }
1533
1534    // ── ProcessConfig ──
1535
1536    #[test]
1537    fn process_config_clone() {
1538        let config = ProcessConfig {
1539            command: CommandSpec::Shell("echo".to_string()),
1540            cwd: Some("/tmp".into()),
1541            env: Some(vec![("KEY".to_string(), "VAL".to_string())]),
1542            capture: true,
1543            stderr_mode: StderrMode::Pipe,
1544            creationflags: Some(0x10),
1545            create_process_group: true,
1546            stdin_mode: StdinMode::Piped,
1547            nice: Some(5),
1548            containment: None,
1549        };
1550        let cloned = config.clone();
1551        assert!(cloned.capture);
1552        assert_eq!(cloned.nice, Some(5));
1553    }
1554
1555    // ── render_rust_debug_traces ──
1556
1557    #[test]
1558    fn render_rust_debug_traces_returns_string() {
1559        let result = render_rust_debug_traces();
1560        let _ = result.len();
1561    }
1562
1563    // ── RustDebugScopeGuard ──
1564
1565    #[test]
1566    fn rust_debug_scope_guard_enters_and_drops() {
1567        let _guard = RustDebugScopeGuard::enter("test_scope", file!(), line!());
1568        let traces = render_rust_debug_traces();
1569        assert!(traces.contains("test_scope"));
1570        drop(_guard);
1571    }
1572
1573    // ── Unix signal tests ──
1574
1575    #[cfg(unix)]
1576    mod unix_tests {
1577        use super::*;
1578
1579        #[test]
1580        fn unix_signal_raw_values() {
1581            assert_eq!(unix_signal_raw(UnixSignal::Interrupt), libc::SIGINT);
1582            assert_eq!(unix_signal_raw(UnixSignal::Terminate), libc::SIGTERM);
1583            assert_eq!(unix_signal_raw(UnixSignal::Kill), libc::SIGKILL);
1584        }
1585
1586        #[test]
1587        fn unix_signal_enum_equality() {
1588            assert_eq!(UnixSignal::Interrupt, UnixSignal::Interrupt);
1589            assert_ne!(UnixSignal::Interrupt, UnixSignal::Kill);
1590        }
1591    }
1592
1593    // ── wait_for_capture_completion ──
1594
1595    #[test]
1596    fn wait_for_capture_completion_noop_without_capture() {
1597        let process = NativeProcess::new(ProcessConfig {
1598            command: CommandSpec::Argv(vec!["echo".into()]),
1599            cwd: None,
1600            env: None,
1601            capture: false,
1602            stderr_mode: StderrMode::Stdout,
1603            creationflags: None,
1604            create_process_group: false,
1605            stdin_mode: StdinMode::Inherit,
1606            nice: None,
1607            containment: None,
1608        });
1609        process.wait_for_capture_completion_impl();
1610    }
1611
1612    // ── build_command tests ──
1613
1614    #[test]
1615    fn build_command_from_argv() {
1616        let process = NativeProcess::new(ProcessConfig {
1617            command: CommandSpec::Argv(vec!["echo".into(), "hello".into(), "world".into()]),
1618            cwd: None,
1619            env: None,
1620            capture: false,
1621            stderr_mode: StderrMode::Stdout,
1622            creationflags: None,
1623            create_process_group: false,
1624            stdin_mode: StdinMode::Inherit,
1625            nice: None,
1626            containment: None,
1627        });
1628        let cmd = process.build_command();
1629        assert_eq!(cmd.get_program(), "echo");
1630        let args: Vec<_> = cmd.get_args().collect();
1631        assert_eq!(args, vec!["hello", "world"]);
1632    }
1633
1634    #[test]
1635    fn build_command_from_shell() {
1636        let process = NativeProcess::new(ProcessConfig {
1637            command: CommandSpec::Shell("echo test".into()),
1638            cwd: None,
1639            env: None,
1640            capture: false,
1641            stderr_mode: StderrMode::Stdout,
1642            creationflags: None,
1643            create_process_group: false,
1644            stdin_mode: StdinMode::Inherit,
1645            nice: None,
1646            containment: None,
1647        });
1648        let cmd = process.build_command();
1649        // Shell commands go through the OS shell
1650        let program = cmd.get_program().to_string_lossy().to_string();
1651        #[cfg(windows)]
1652        assert!(
1653            program.contains("cmd"),
1654            "expected cmd shell, got {}",
1655            program
1656        );
1657        #[cfg(not(windows))]
1658        assert!(program.contains("sh"), "expected sh shell, got {}", program);
1659    }
1660
1661    #[test]
1662    fn build_command_with_cwd() {
1663        let tmp = std::env::temp_dir();
1664        let process = NativeProcess::new(ProcessConfig {
1665            command: CommandSpec::Argv(vec!["echo".into()]),
1666            cwd: Some(tmp.clone()),
1667            env: None,
1668            capture: false,
1669            stderr_mode: StderrMode::Stdout,
1670            creationflags: None,
1671            create_process_group: false,
1672            stdin_mode: StdinMode::Inherit,
1673            nice: None,
1674            containment: None,
1675        });
1676        let cmd = process.build_command();
1677        assert_eq!(cmd.get_current_dir().unwrap(), &tmp);
1678    }
1679
1680    #[test]
1681    fn build_command_with_env() {
1682        let process = NativeProcess::new(ProcessConfig {
1683            command: CommandSpec::Argv(vec!["echo".into()]),
1684            cwd: None,
1685            env: Some(vec![
1686                ("FOO".into(), "bar".into()),
1687                ("BAZ".into(), "qux".into()),
1688            ]),
1689            capture: false,
1690            stderr_mode: StderrMode::Stdout,
1691            creationflags: None,
1692            create_process_group: false,
1693            stdin_mode: StdinMode::Inherit,
1694            nice: None,
1695            containment: None,
1696        });
1697        let cmd = process.build_command();
1698        let envs: Vec<_> = cmd.get_envs().collect();
1699        assert!(envs
1700            .iter()
1701            .any(|(k, v)| *k == "FOO" && *v == Some(std::ffi::OsStr::new("bar"))));
1702        assert!(envs
1703            .iter()
1704            .any(|(k, v)| *k == "BAZ" && *v == Some(std::ffi::OsStr::new("qux"))));
1705    }
1706
1707    #[test]
1708    fn build_command_single_argv() {
1709        let process = NativeProcess::new(ProcessConfig {
1710            command: CommandSpec::Argv(vec!["echo".into()]),
1711            cwd: None,
1712            env: None,
1713            capture: false,
1714            stderr_mode: StderrMode::Stdout,
1715            creationflags: None,
1716            create_process_group: false,
1717            stdin_mode: StdinMode::Inherit,
1718            nice: None,
1719            containment: None,
1720        });
1721        let cmd = process.build_command();
1722        assert_eq!(cmd.get_program(), "echo");
1723        assert_eq!(cmd.get_args().count(), 0);
1724    }
1725
1726    // ── set_returncode tests ──
1727
1728    #[test]
1729    fn set_returncode_updates_shared_state() {
1730        let process = NativeProcess::new(ProcessConfig {
1731            command: CommandSpec::Argv(vec!["echo".into()]),
1732            cwd: None,
1733            env: None,
1734            capture: false,
1735            stderr_mode: StderrMode::Stdout,
1736            creationflags: None,
1737            create_process_group: false,
1738            stdin_mode: StdinMode::Inherit,
1739            nice: None,
1740            containment: None,
1741        });
1742        assert!(process.returncode().is_none());
1743        process.set_returncode(42);
1744        assert_eq!(process.returncode(), Some(42));
1745    }
1746
1747    #[test]
1748    fn set_returncode_overwrites() {
1749        let process = NativeProcess::new(ProcessConfig {
1750            command: CommandSpec::Argv(vec!["echo".into()]),
1751            cwd: None,
1752            env: None,
1753            capture: false,
1754            stderr_mode: StderrMode::Stdout,
1755            creationflags: None,
1756            create_process_group: false,
1757            stdin_mode: StdinMode::Inherit,
1758            nice: None,
1759            containment: None,
1760        });
1761        process.set_returncode(1);
1762        process.set_returncode(2);
1763        assert_eq!(process.returncode(), Some(2));
1764    }
1765
1766    // ── SharedState with capture ──
1767
1768    #[test]
1769    fn shared_state_with_capture_queues_open() {
1770        let state = SharedState::new(true);
1771        let guard = state.queues.lock().unwrap();
1772        assert!(!guard.stdout_closed);
1773        assert!(!guard.stderr_closed);
1774    }
1775
1776    #[test]
1777    fn shared_state_without_capture_queues_closed() {
1778        let state = SharedState::new(false);
1779        let guard = state.queues.lock().unwrap();
1780        assert!(guard.stdout_closed);
1781        assert!(guard.stderr_closed);
1782    }
1783
1784    // ── ProcessError Display additional variants ──
1785
1786    #[test]
1787    fn process_error_display_io_variant() {
1788        let err = ProcessError::Io(std::io::Error::new(
1789            std::io::ErrorKind::BrokenPipe,
1790            "pipe broken",
1791        ));
1792        let msg = format!("{}", err);
1793        assert!(msg.contains("pipe broken"));
1794    }
1795
1796    #[test]
1797    fn process_error_display_spawn_variant() {
1798        let err = ProcessError::Spawn(std::io::Error::new(
1799            std::io::ErrorKind::NotFound,
1800            "not found",
1801        ));
1802        let msg = format!("{}", err);
1803        assert!(msg.contains("not found"));
1804    }
1805
1806    // ── shell_command produces a command ──
1807
1808    #[test]
1809    fn shell_command_returns_command_with_shell() {
1810        let cmd = shell_command("echo test");
1811        let program = cmd.get_program().to_string_lossy().to_string();
1812        #[cfg(windows)]
1813        assert!(program.contains("cmd"));
1814        #[cfg(not(windows))]
1815        assert!(program.contains("sh"));
1816    }
1817}