Skip to main content

running_process/
lib.rs

1//! Cross-platform process execution, process-tree control, PTY handling, and
2//! broker integration primitives.
3//!
4//! The crate exposes a synchronous process API through [`NativeProcess`], a
5//! contained process-group helper through [`ContainedProcessGroup`], low-level
6//! spawn helpers through [`spawn()`] and [`spawn_daemon`], and optional
7//! daemon/broker modules behind feature flags.
8
9use std::collections::VecDeque;
10use std::io::Read;
11#[cfg(unix)]
12use std::os::fd::{AsRawFd, RawFd};
13#[cfg(unix)]
14use std::os::unix::net::UnixStream;
15use std::process::{Child, ChildStdin, Command, Stdio};
16use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
17use std::sync::{Arc, Condvar, Mutex};
18use std::thread;
19use std::time::{Duration, Instant};
20
21use crate::observer::ObserverEmitter;
22
23#[cfg(feature = "async-process")]
24mod async_process;
25pub mod console_detect;
26pub mod containment;
27pub mod environment;
28mod helpers;
29#[cfg(feature = "async-process")]
30mod process_runtime;
31pub mod window_icon;
32// Phase 1 of #221: process-observation capability model + portable
33// lifecycle baseline. Core-feature-clean (std-only: mpsc + SystemTime),
34// so the started/exited baseline is available to the base library
35// without pulling in the daemon runtime.
36pub mod observer;
37#[cfg(feature = "originator-scan")]
38pub mod originator;
39// Wave 3+4 of #165: proto module + IPC client absorbed from the
40// former `running-process-proto` and `running-process-client` crates.
41// Both gated behind `feature = "client"`. The protobuf package
42// `running_process.daemon.v1` compiles to the file referenced below.
43#[cfg(feature = "client")]
44/// Prost-generated daemon protocol types used by the client transport.
45pub mod proto {
46    /// Generated Rust bindings for the `running_process.daemon.v1` protobuf package.
47    #[allow(missing_docs)]
48    pub mod daemon {
49        include!(concat!(env!("OUT_DIR"), "/running_process.daemon.v1.rs"));
50    }
51}
52
53#[cfg(feature = "client")]
54pub mod client;
55
56// Phase 0 of #228: v1 broker module — prost-generated wire types from
57// `proto/broker_v1_*.proto`. Gated behind `feature = "client"` because
58// prost itself is optional under that feature. Schemas are
59// FROZEN FOREVER once v1.0 ships.
60#[cfg(feature = "client")]
61pub mod broker;
62
63/// Probe client facade (#633). Gated on the `probe` feature so a build
64/// without it contains none of this code.
65#[cfg(feature = "probe")]
66pub mod probe;
67
68// Phase 1 of #228 (issue #230): maintenance subcommands exposed via
69// the `runpm` CLI. Currently just `release-handles` — a cross-platform
70// scaffold for the Windows worktree-teardown handle-race fix
71// (soldr#710). Gated behind `feature = "client"` because the CLI that
72// drives it is.
73#[cfg(feature = "client")]
74pub mod maintenance;
75
76#[cfg(feature = "client")]
77pub mod cleanup;
78
79// Phase 4 of #222 (#427): per-OS boot autostart for the runpm daemon.
80// Gated behind `feature = "client"` because the only consumer is the
81// `runpm` CLI binary, which is itself client-gated.
82#[cfg(feature = "client")]
83pub mod boot_autostart;
84
85// Phase 5 of #222 (#428): `runpm.toml` parser used by the `runpm` CLI
86// to batch-start `[[app]]` entries. Lives in the library (not under
87// `src/bin/`) so the integration test in `tests/runpm_toml_config.rs`
88// can drive the same code path the binary uses.
89#[cfg(feature = "client")]
90pub mod runpm_config;
91
92// #415: consumer-consumable conformance test kit. Gated behind the
93// off-by-default `test-support` cargo feature (which implies `client`)
94// so the helpers ship in the published crate but only compile when a
95// consumer opts in as a dev-dependency.
96#[cfg(feature = "test-support")]
97pub mod test_support;
98
99// Lightweight tee sink primitives for callers that want transcript/log
100// fan-out without pulling in the full daemon runtime.
101#[cfg(feature = "telemetry")]
102#[path = "daemon/telemetry.rs"]
103pub mod telemetry;
104
105// Wave 5 of #165: daemon runtime absorbed from `running-process-daemon`.
106// Heavy deps (tokio, sqlite, etc.) gated behind `feature = "daemon"`.
107#[cfg(feature = "daemon")]
108/// Daemon runtime APIs and helpers enabled by the `daemon` feature.
109pub mod daemon;
110pub mod process_tree;
111#[cfg(feature = "pty")]
112/// PTY-backed process APIs.
113pub mod pty;
114mod public_symbols;
115mod rust_debug;
116pub mod spawn;
117pub mod systemd_killmode;
118pub mod terminal_graphics;
119mod types;
120#[cfg(unix)]
121mod unix;
122#[cfg(windows)]
123mod windows;
124
125#[cfg(feature = "async-process")]
126pub use async_process::AsyncProcess;
127pub use console_detect::{monitor_console_windows, ConsoleWindowInfo};
128pub use containment::{ContainedProcessGroup, ORIGINATOR_ENV_VAR};
129pub use observer::{
130    CapabilitySupport, CategoryCapability, EventCategory, ObserverCapabilities, ObserverConfig,
131    ObserverEvent, ObserverEventKind, ObserverSubscriber,
132};
133#[cfg(feature = "originator-scan")]
134pub use originator::{
135    find_declared_daemon_pids, find_processes_by_originator, OriginatorProcessInfo,
136};
137pub use rust_debug::{render_rust_debug_traces, RustDebugScopeGuard};
138pub use spawn::{
139    spawn, spawn_daemon, spawn_daemon_breaking_away_from_job,
140    spawn_daemon_breaking_away_with_env_policy, spawn_daemon_with_clear_env,
141    spawn_daemon_with_env_policy, spawn_daemon_with_stdio, spawn_daemon_with_stdio_and_env_policy,
142    spawn_with_env_policy, DaemonChild, DaemonStdio, DaemonStdioSource, EnvironmentPolicy,
143    SpawnStdio, SpawnedChild, StdioSource, DAEMON_MARKER_ENV_VAR,
144};
145#[cfg(feature = "client-async")]
146pub use spawn::{spawn_tokio, TokioSpawnOptions};
147pub use terminal_graphics::{
148    current_terminal_capabilities, current_terminal_capabilities_with_timeout,
149    detect_terminal_capabilities, CapabilityStatus, EvidenceStrength, GraphicsCapability,
150    GraphicsProtocol, TerminalCapabilities, TerminalCapabilityInput, TerminalGraphicsCapabilities,
151    TerminalProbeEvidence,
152};
153pub use types::{
154    CommandSpec, ProcessConfig, ProcessError, ReadStatus, RunOutput, StderrMode, StdinMode,
155    StreamEvent, StreamKind,
156};
157pub use window_icon::{
158    host_icon_support, icon_support, set_host_icon, set_icon, IconError, IconScope, IconSource,
159    IconSupport, StockIcon,
160};
161
162#[cfg(unix)]
163pub(crate) use helpers::{
164    child_signal_disposition, child_try_wait_error_is_retryable, completed_reap_after_signal,
165    poll_mutex_until, with_child_lock_for_signal, ChildSignalDisposition,
166};
167pub(crate) use helpers::{exit_code, feed_chunk, kill_drain_deadline, log_spawned_child_pid};
168#[cfg(unix)]
169pub use unix::{unix_set_priority, unix_signal_process, unix_signal_process_group, UnixSignal};
170#[cfg(windows)]
171pub(crate) use windows::{
172    assign_child_to_windows_kill_on_close_job_impl, windows_creation_flags, CapturePipeHandles,
173    WindowsJobHandle,
174};
175
176#[macro_export]
177/// Create a scoped Rust debug trace label for the current function body.
178macro_rules! rp_rust_debug_scope {
179    ($label:expr) => {
180        let _running_process_rust_debug_scope =
181            $crate::RustDebugScopeGuard::enter($label, file!(), line!());
182    };
183}
184
185#[derive(Default)]
186struct QueueState {
187    stdout_queue: VecDeque<Vec<u8>>,
188    stderr_queue: VecDeque<Vec<u8>>,
189    combined_queue: VecDeque<StreamEvent>,
190    stdout_history: VecDeque<Vec<u8>>,
191    stderr_history: VecDeque<Vec<u8>>,
192    combined_history: VecDeque<StreamEvent>,
193    stdout_raw: Vec<u8>,
194    stderr_raw: Vec<u8>,
195    stdout_history_bytes: usize,
196    stderr_history_bytes: usize,
197    combined_history_bytes: usize,
198    stdout_closed: bool,
199    stderr_closed: bool,
200}
201
202/// Sentinel value for returncode atomic: process has not exited yet.
203const RETURNCODE_NOT_SET: i64 = i64::MIN;
204
205struct SharedState {
206    queues: Mutex<QueueState>,
207    condvar: Condvar,
208    capture_limit: Option<usize>,
209    capture_overflowed: AtomicBool,
210    active_capture_readers: std::sync::atomic::AtomicUsize,
211    /// Atomic exit code. `RETURNCODE_NOT_SET` means "not exited yet".
212    /// Updated by a background waiter thread — reading is lock-free.
213    returncode: AtomicI64,
214    /// Phase 1 of #221: optional lifecycle-event emitter. `None` means
215    /// observation is off (the off-by-default path), so the lifecycle
216    /// hooks are inert. When `Some`, `started` is emitted once at spawn
217    /// and `exited` exactly once on the first returncode transition.
218    observer: Option<ObserverEmitter>,
219    /// Guards against emitting more than one `exited` event when several
220    /// code paths (waiter thread, `poll`, `kill`) race to record the exit.
221    observer_exit_emitted: AtomicBool,
222}
223
224struct ChildState {
225    child: Child,
226    #[cfg(windows)]
227    _job: WindowsJobHandle,
228}
229
230#[cfg(unix)]
231#[derive(Default)]
232struct UnixCaptureWakers {
233    stdout: Option<UnixStream>,
234    stderr: Option<UnixStream>,
235}
236
237#[cfg(unix)]
238struct UnixCancelableReader<R> {
239    reader: R,
240    wake_reader: UnixStream,
241}
242
243#[cfg(any(test, unix))]
244#[derive(Debug, Eq, PartialEq)]
245enum CapturePollAction {
246    Wait,
247    Read,
248    Cancel,
249}
250
251#[cfg(any(test, unix))]
252fn capture_poll_action(capture_revents: i16, wake_revents: i16) -> CapturePollAction {
253    if wake_revents != 0 {
254        CapturePollAction::Cancel
255    } else if capture_revents != 0 {
256        CapturePollAction::Read
257    } else {
258        CapturePollAction::Wait
259    }
260}
261
262#[cfg(unix)]
263impl<R: Read + AsRawFd> Read for UnixCancelableReader<R> {
264    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
265        if buf.is_empty() {
266            return Ok(0);
267        }
268        loop {
269            let mut poll_fds = [
270                libc::pollfd {
271                    fd: self.reader.as_raw_fd(),
272                    events: libc::POLLIN | libc::POLLHUP | libc::POLLERR,
273                    revents: 0,
274                },
275                libc::pollfd {
276                    fd: self.wake_reader.as_raw_fd(),
277                    events: libc::POLLIN | libc::POLLHUP | libc::POLLERR,
278                    revents: 0,
279                },
280            ];
281            let polled = unsafe { libc::poll(poll_fds.as_mut_ptr(), poll_fds.len() as _, -1) };
282            if polled < 0 {
283                let error = std::io::Error::last_os_error();
284                if error.kind() == std::io::ErrorKind::Interrupted {
285                    continue;
286                }
287                return Err(error);
288            }
289            match capture_poll_action(poll_fds[0].revents, poll_fds[1].revents) {
290                CapturePollAction::Cancel => {
291                    return Err(std::io::Error::new(
292                        std::io::ErrorKind::Interrupted,
293                        "capture reader cancelled",
294                    ));
295                }
296                CapturePollAction::Read => match self.reader.read(buf) {
297                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => continue,
298                    result => return result,
299                },
300                CapturePollAction::Wait => {}
301            }
302        }
303    }
304}
305
306#[cfg(unix)]
307fn set_nonblocking(fd: RawFd) -> std::io::Result<()> {
308    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
309    if flags < 0 {
310        return Err(std::io::Error::last_os_error());
311    }
312    if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
313        return Err(std::io::Error::last_os_error());
314    }
315    Ok(())
316}
317
318#[cfg(unix)]
319fn cleanup_child_after_start_error(mut child: Child) {
320    let _ = child.kill();
321    // Keep start() bounded while retaining ownership until the child is
322    // eventually reaped, even if the OS takes time to deliver SIGKILL.
323    thread::spawn(move || {
324        let _ = child.wait();
325    });
326}
327
328impl SharedState {
329    #[cfg(test)]
330    fn new(capture: bool) -> Self {
331        Self::with_observer_and_limit(capture, None, None)
332    }
333
334    fn with_observer_and_limit(
335        capture: bool,
336        observer: Option<ObserverEmitter>,
337        capture_limit: Option<usize>,
338    ) -> Self {
339        let queues = QueueState {
340            stdout_closed: !capture,
341            stderr_closed: !capture,
342            ..QueueState::default()
343        };
344        Self {
345            queues: Mutex::new(queues),
346            condvar: Condvar::new(),
347            capture_limit,
348            capture_overflowed: AtomicBool::new(false),
349            active_capture_readers: std::sync::atomic::AtomicUsize::new(0),
350            returncode: AtomicI64::new(RETURNCODE_NOT_SET),
351            observer,
352            observer_exit_emitted: AtomicBool::new(false),
353        }
354    }
355
356    /// Emit the lifecycle `exited` event exactly once, regardless of which
357    /// code path first observes the exit. No-op when observation is off.
358    fn emit_exited(&self, pid: u32, exit_code: i32) {
359        let Some(emitter) = self.observer.as_ref() else {
360            return;
361        };
362        if self
363            .observer_exit_emitted
364            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
365            .is_ok()
366        {
367            emitter.emit_exited(pid, exit_code);
368        }
369    }
370}
371
372/// A cross-platform child process with optional output capture.
373///
374/// `NativeProcess` wraps [`std::process::Command`] with the crate's
375/// process-tree containment, capture draining, timeout, and terminal-control
376/// behavior. Methods are synchronous and are safe to call from ordinary
377/// blocking code.
378pub struct NativeProcess {
379    config: ProcessConfig,
380    command_override: Mutex<Option<Command>>,
381    child: Arc<Mutex<Option<ChildState>>>,
382    stdin: Mutex<Option<ChildStdin>>,
383    shared: Arc<SharedState>,
384    #[cfg(test)]
385    stdin_write_active: AtomicBool,
386    #[cfg(windows)]
387    capture_pipe_handles: Arc<Mutex<CapturePipeHandles>>,
388    #[cfg(unix)]
389    capture_wakers: Arc<Mutex<UnixCaptureWakers>>,
390}
391
392impl NativeProcess {
393    /// Create a process wrapper from a [`ProcessConfig`].
394    ///
395    /// The child is not spawned until [`Self::start`] is called. Process
396    /// observation is **off by default**: no lifecycle events are emitted
397    /// unless [`Self::with_observer`] is used instead.
398    pub fn new(config: ProcessConfig) -> Self {
399        Self::new_with_options(config, None, None, None)
400    }
401
402    /// Create a process wrapper with process observation enabled (Phase 1
403    /// of #221).
404    ///
405    /// Returns the wrapper paired with an [`ObserverSubscriber`] that
406    /// receives a [`started`](crate::ObserverEventKind::Started) event when
407    /// [`Self::start`] spawns the child and exactly one
408    /// [`exited`](crate::ObserverEventKind::Exited) event when the child is
409    /// reaped — for the categories the `config` requests that are actually
410    /// `Supported` (only [`Lifecycle`](crate::EventCategory::Lifecycle) in
411    /// Phase 1; see [`ObserverCapabilities::negotiate`](crate::ObserverCapabilities::negotiate)).
412    ///
413    /// The emitter never blocks on a slow or dropped subscriber.
414    pub fn with_observer(
415        config: ProcessConfig,
416        observer: crate::observer::ObserverConfig,
417    ) -> (Self, ObserverSubscriber) {
418        let (emitter, subscriber) = ObserverEmitter::new(observer);
419        let process = Self::new_with_options(config, Some(emitter), None, None);
420        (process, subscriber)
421    }
422
423    fn new_with_capture_limit(config: ProcessConfig, capture_limit: usize) -> Self {
424        Self::new_with_options(config, None, Some(capture_limit), None)
425    }
426
427    fn new_with_command_capture_limit(
428        command: Command,
429        config: ProcessConfig,
430        capture_limit: usize,
431    ) -> Self {
432        Self::new_with_options(config, None, Some(capture_limit), Some(command))
433    }
434
435    fn new_with_options(
436        config: ProcessConfig,
437        observer: Option<ObserverEmitter>,
438        capture_limit: Option<usize>,
439        command_override: Option<Command>,
440    ) -> Self {
441        let shared = SharedState::with_observer_and_limit(config.capture, observer, capture_limit);
442        Self {
443            shared: Arc::new(shared),
444            command_override: Mutex::new(command_override),
445            child: Arc::new(Mutex::new(None)),
446            stdin: Mutex::new(None),
447            #[cfg(test)]
448            stdin_write_active: AtomicBool::new(false),
449            config,
450            #[cfg(windows)]
451            capture_pipe_handles: Arc::new(Mutex::new(CapturePipeHandles::default())),
452            #[cfg(unix)]
453            capture_wakers: Arc::new(Mutex::new(UnixCaptureWakers::default())),
454        }
455    }
456
457    // Preserve a stable Rust frame here in release user dumps.
458    #[inline(never)]
459    /// Spawn the configured child process.
460    ///
461    /// Returns [`ProcessError::AlreadyStarted`] if the same wrapper already
462    /// owns a running child.
463    pub fn start(&self) -> Result<(), ProcessError> {
464        public_symbols::rp_native_process_start_public(self)
465    }
466
467    fn start_impl(&self) -> Result<(), ProcessError> {
468        crate::rp_rust_debug_scope!("running_process::NativeProcess::start");
469        let mut guard = self.child.lock().expect("child mutex poisoned");
470        if guard.is_some() {
471            return Err(ProcessError::AlreadyStarted);
472        }
473
474        let mut command = self.build_command();
475        match self.config.stdin_mode {
476            StdinMode::Inherit => {}
477            StdinMode::Piped => {
478                command.stdin(Stdio::piped());
479            }
480            StdinMode::Null => {
481                command.stdin(Stdio::null());
482            }
483        }
484        if self.config.capture {
485            command.stdout(Stdio::piped());
486            command.stderr(Stdio::piped());
487        }
488
489        let mut child = command.spawn().map_err(ProcessError::Spawn)?;
490        log_spawned_child_pid(child.id()).map_err(ProcessError::Spawn)?;
491        // Phase 1 of #221: emit the lifecycle `started` event. No-op when
492        // observation is off (the common, off-by-default path).
493        if let Some(emitter) = self.shared.observer.as_ref() {
494            emitter.emit_started(child.id());
495        }
496        // #539 slice 2: when the observer requests EventCategory::Process,
497        // associate an IOCP with the per-spawn Job Object so a pump thread
498        // can forward descendant lifecycle events. The Lifecycle category
499        // is still served by emit_started / emit_exited above and below.
500        #[cfg(windows)]
501        let job = {
502            let descendant_sink = self
503                .shared
504                .observer
505                .as_ref()
506                .and_then(|e| e.descendant_sink());
507            let direct_pid = child.id();
508            public_symbols::rp_assign_child_to_windows_kill_on_close_job_with_observer_public(
509                &child,
510                descendant_sink,
511                direct_pid,
512            )
513            .map_err(ProcessError::Spawn)?
514        };
515        // #539 slice 5: Linux descendant lifecycle via PR_SET_CHILD_SUBREAPER
516        // + /proc polling pump. No-admin, polling-based — see
517        // observer::descendants_linux module docs for tradeoffs.
518        #[cfg(target_os = "linux")]
519        {
520            if let Some(emitter) = self.shared.observer.as_ref() {
521                if let Some((sink, stop)) = emitter.descendant_pump() {
522                    crate::observer::descendants_linux::enable_subreaper();
523                    crate::observer::descendants_linux::spawn_pump(child.id(), sink, stop);
524                }
525            }
526        }
527        // #539 slice 7: macOS descendant lifecycle via kqueue + EVFILT_PROC
528        // + NOTE_TRACK. Fully event-driven (no polling) — see
529        // observer::descendants_macos module docs for tradeoffs.
530        #[cfg(target_os = "macos")]
531        {
532            if let Some(emitter) = self.shared.observer.as_ref() {
533                if let Some((sink, stop)) = emitter.descendant_pump() {
534                    crate::observer::descendants_macos::spawn_pump(child.id(), sink, stop);
535                }
536            }
537        }
538        if self.config.capture {
539            let stdout = child.stdout.take().expect("stdout pipe missing");
540            let stderr = child.stderr.take().expect("stderr pipe missing");
541            #[cfg(windows)]
542            {
543                use std::os::windows::io::AsRawHandle;
544                let mut handles = self
545                    .capture_pipe_handles
546                    .lock()
547                    .expect("capture pipe handles mutex poisoned");
548                handles.stdout = Some(stdout.as_raw_handle() as usize);
549                handles.stderr = Some(stderr.as_raw_handle() as usize);
550            }
551            #[cfg(unix)]
552            let ((stdout, stdout_waker), (stderr, stderr_waker)) =
553                match Self::prepare_unix_capture_reader(stdout).and_then(|stdout| {
554                    Self::prepare_unix_capture_reader(stderr).map(|stderr| (stdout, stderr))
555                }) {
556                    Ok(readers) => readers,
557                    Err(error) => {
558                        cleanup_child_after_start_error(child);
559                        return Err(ProcessError::Spawn(error));
560                    }
561                };
562            #[cfg(unix)]
563            {
564                let mut wakers = self
565                    .capture_wakers
566                    .lock()
567                    .expect("capture wakers mutex poisoned");
568                wakers.stdout = Some(stdout_waker);
569                wakers.stderr = Some(stderr_waker);
570            }
571            self.spawn_reader(
572                stdout,
573                StreamKind::Stdout,
574                StreamKind::Stdout,
575                self.pipe_done_callback(StreamKind::Stdout),
576            );
577            self.spawn_reader(
578                stderr,
579                StreamKind::Stderr,
580                match self.config.stderr_mode {
581                    StderrMode::Stdout => StreamKind::Stdout,
582                    StderrMode::Pipe => StreamKind::Stderr,
583                },
584                self.pipe_done_callback(StreamKind::Stderr),
585            );
586        }
587        *self.stdin.lock().expect("stdin mutex poisoned") = child.stdin.take();
588        *guard = Some(ChildState {
589            child,
590            #[cfg(windows)]
591            _job: job,
592        });
593        drop(guard);
594        self.spawn_exit_waiter();
595        Ok(())
596    }
597
598    /// Background thread that polls for process exit and stores the exit code
599    /// atomically. This makes `returncode` auto-update without explicit `poll()`.
600    fn spawn_exit_waiter(&self) {
601        let child = Arc::clone(&self.child);
602        let shared = Arc::clone(&self.shared);
603        let capture = self.config.capture;
604        #[cfg(windows)]
605        let capture_pipe_handles = Arc::clone(&self.capture_pipe_handles);
606        #[cfg(unix)]
607        let capture_wakers = Arc::clone(&self.capture_wakers);
608        thread::spawn(move || {
609            loop {
610                if shared.returncode.load(Ordering::Acquire) != RETURNCODE_NOT_SET {
611                    return;
612                }
613                let exited = {
614                    let mut guard = child.lock().expect("child mutex poisoned");
615                    if let Some(child_state) = guard.as_mut() {
616                        let pid = child_state.child.id();
617                        match child_state.child.try_wait() {
618                            Ok(Some(status)) => {
619                                let code = exit_code(status);
620                                shared.returncode.store(code as i64, Ordering::Release);
621                                // Phase 1 of #221: lifecycle `exited`. Emit
622                                // before notifying waiters and is guarded so
623                                // only the first exit-observer fires.
624                                shared.emit_exited(pid, code);
625                                shared.condvar.notify_all();
626                                true
627                            }
628                            Ok(None) => false,
629                            Err(_error) => {
630                                #[cfg(unix)]
631                                if child_try_wait_error_is_retryable(&_error) {
632                                    false
633                                } else {
634                                    return;
635                                }
636                                #[cfg(windows)]
637                                return;
638                            }
639                        }
640                    } else {
641                        return;
642                    }
643                };
644                if exited {
645                    // The direct child has exited. Bound the capture-completion
646                    // wait so wait()/close()/read_* on the natural-exit path
647                    // cannot wedge forever when a grandchild inherited the pipe
648                    // and outlives the child (issue #590, cluster A). Unlike
649                    // `kill_impl` we do NOT cancel the reader up front: a
650                    // short-lived grandchild may still emit output the caller
651                    // expects to capture, so the reader is left to drain
652                    // naturally within the grace window. Only if the window
653                    // elapses with the pipe still held open do we cancel, to
654                    // release the otherwise-leaked reader thread (Windows:
655                    // CancelIoEx; Unix: a per-reader wake socket). The child
656                    // lock is released before this
657                    // potentially-blocking finalize so poll()/kill() are never
658                    // held off.
659                    if capture {
660                        let drained = finalize_capture_completion(&shared, kill_drain_deadline());
661                        #[cfg(windows)]
662                        if !drained {
663                            cancel_capture_pipe_io(&capture_pipe_handles);
664                        }
665                        #[cfg(unix)]
666                        if !drained {
667                            cancel_capture_pipe_io(&capture_wakers);
668                        }
669                        #[cfg(not(any(windows, unix)))]
670                        let _ = drained;
671                    }
672                    return;
673                }
674                // #199: intentional — capture thread polling for
675                // child-exit. `try_wait` is non-blocking by design;
676                // we can't block here because the thread also drains
677                // pipe state alongside the exit check. 10ms keeps the
678                // CPU cost negligible while staying responsive.
679                thread::sleep(Duration::from_millis(10));
680            }
681        });
682    }
683
684    /// Write bytes to the child's stdin and then close stdin.
685    pub fn write_stdin(&self, data: &[u8]) -> Result<(), ProcessError> {
686        if self.child.lock().expect("child mutex poisoned").is_none() {
687            return Err(ProcessError::NotRunning);
688        }
689        let mut guard = self.stdin.lock().expect("stdin mutex poisoned");
690        let stdin = guard.as_mut().ok_or(ProcessError::StdinUnavailable)?;
691        use std::io::Write;
692        #[cfg(test)]
693        self.stdin_write_active.store(true, Ordering::Release);
694        let write_result = stdin.write_all(data);
695        #[cfg(test)]
696        self.stdin_write_active.store(false, Ordering::Release);
697        write_result.map_err(ProcessError::Io)?;
698        stdin.flush().map_err(ProcessError::Io)?;
699        drop(guard.take());
700        Ok(())
701    }
702
703    /// Write to the child's stdin without closing it afterwards, so the
704    /// caller can issue additional writes. Used by interactive
705    /// pipe-backed sessions (#130 milestone 3) where the daemon keeps
706    /// stdin open across multiple client input frames.
707    pub fn write_stdin_streaming(&self, data: &[u8]) -> Result<(), ProcessError> {
708        if self.child.lock().expect("child mutex poisoned").is_none() {
709            return Err(ProcessError::NotRunning);
710        }
711        let mut guard = self.stdin.lock().expect("stdin mutex poisoned");
712        let stdin = guard.as_mut().ok_or(ProcessError::StdinUnavailable)?;
713        use std::io::Write;
714        #[cfg(test)]
715        self.stdin_write_active.store(true, Ordering::Release);
716        let write_result = stdin.write_all(data);
717        #[cfg(test)]
718        self.stdin_write_active.store(false, Ordering::Release);
719        write_result.map_err(ProcessError::Io)?;
720        stdin.flush().map_err(ProcessError::Io)?;
721        Ok(())
722    }
723
724    /// Explicitly close the child's stdin (signals EOF to the child).
725    /// Idempotent: returns Ok if stdin was already closed.
726    pub fn close_stdin(&self) -> Result<(), ProcessError> {
727        if self.child.lock().expect("child mutex poisoned").is_none() {
728            return Err(ProcessError::NotRunning);
729        }
730        drop(self.stdin.lock().expect("stdin mutex poisoned").take());
731        Ok(())
732    }
733
734    /// Check whether the child has exited without blocking.
735    ///
736    /// Returns `Ok(None)` while the process is still running.
737    pub fn poll(&self) -> Result<Option<i32>, ProcessError> {
738        // Fast path: check atomic set by background waiter thread.
739        if let Some(code) = self.returncode() {
740            return Ok(Some(code));
741        }
742        let mut guard = self.child.lock().expect("child mutex poisoned");
743        let Some(child_state) = guard.as_mut() else {
744            return Ok(self.returncode());
745        };
746        let pid = child_state.child.id();
747        let child = &mut child_state.child;
748        let status = child.try_wait().map_err(ProcessError::Io)?;
749        if let Some(status) = status {
750            let code = exit_code(status);
751            self.set_returncode(code);
752            self.shared.emit_exited(pid, code);
753            return Ok(Some(code));
754        }
755        Ok(None)
756    }
757
758    // Preserve a stable Rust frame here in release user dumps.
759    #[inline(never)]
760    /// Wait for the child to exit.
761    ///
762    /// When `timeout` is `Some`, returns [`ProcessError::Timeout`] if the
763    /// child does not exit before the duration elapses.
764    pub fn wait(&self, timeout: Option<Duration>) -> Result<i32, ProcessError> {
765        public_symbols::rp_native_process_wait_public(self, timeout)
766    }
767
768    fn wait_impl(&self, timeout: Option<Duration>) -> Result<i32, ProcessError> {
769        crate::rp_rust_debug_scope!("running_process::NativeProcess::wait");
770        if self.child.lock().expect("child mutex poisoned").is_none() {
771            return self.returncode().ok_or(ProcessError::NotRunning);
772        }
773        // Fast path: already exited.
774        if let Some(code) = self.returncode() {
775            self.finish_capture_drain();
776            return Ok(code);
777        }
778        let start = Instant::now();
779        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
780        loop {
781            // Check returncode (set by exit-waiter thread via atomic + condvar).
782            let rc = self.shared.returncode.load(Ordering::Acquire);
783            if rc != RETURNCODE_NOT_SET {
784                drop(guard);
785                let code = rc as i32;
786                self.finish_capture_drain();
787                return Ok(code);
788            }
789            if let Some(limit) = timeout {
790                let elapsed = start.elapsed();
791                if elapsed >= limit {
792                    return Err(ProcessError::Timeout);
793                }
794                let remaining = limit - elapsed;
795                // Wait on condvar with timeout, capped at 50ms to recheck.
796                let wait_time = remaining.min(Duration::from_millis(50));
797                guard = self
798                    .shared
799                    .condvar
800                    .wait_timeout(guard, wait_time)
801                    .expect("queue mutex poisoned")
802                    .0;
803            } else {
804                // Wait on condvar with periodic recheck.
805                guard = self
806                    .shared
807                    .condvar
808                    .wait_timeout(guard, Duration::from_millis(50))
809                    .expect("queue mutex poisoned")
810                    .0;
811            }
812        }
813    }
814
815    // Preserve a stable Rust frame here in release user dumps.
816    #[inline(never)]
817    /// Forcefully terminate the child process.
818    pub fn kill(&self) -> Result<(), ProcessError> {
819        public_symbols::rp_native_process_kill_public(self)
820    }
821
822    fn kill_impl(&self) -> Result<(), ProcessError> {
823        crate::rp_rust_debug_scope!("running_process::NativeProcess::kill");
824        #[cfg(windows)]
825        {
826            let mut guard = self.child.lock().expect("child mutex poisoned");
827            let child = &mut guard.as_mut().ok_or(ProcessError::NotRunning)?.child;
828            let pid = child.id();
829            child.kill().map_err(ProcessError::Io)?;
830            let status = child.wait().map_err(ProcessError::Io)?;
831            let code = exit_code(status);
832            self.set_returncode(code);
833            // Phase 1 of #221: a killed child still produces a lifecycle
834            // `exited` event (guarded against double-emit by the waiter).
835            self.shared.emit_exited(pid, code);
836        }
837        #[cfg(unix)]
838        {
839            let deadline = kill_drain_deadline();
840            let (pid, already_reaped) = with_child_lock_for_signal(&self.child, |state| {
841                let child = &mut state.as_mut().ok_or(ProcessError::NotRunning)?.child;
842                let pid = child.id();
843                match child_signal_disposition(child.try_wait()).map_err(ProcessError::Io)? {
844                    ChildSignalDisposition::AlreadyExited(status) => Ok((pid, Some(status))),
845                    ChildSignalDisposition::Signal => {
846                        let group_signaled = self.config.create_process_group
847                            && unix_signal_process_group(pid as i32, UnixSignal::Kill).is_ok();
848                        if !group_signaled {
849                            child.kill().map_err(ProcessError::Io)?;
850                        }
851                        Ok((pid, None))
852                    }
853                }
854            })?;
855
856            // Wake capture readers immediately after signal delivery. In
857            // particular, this prevents a surviving pipe-owning descendant
858            // from extending the bounded reap window.
859            self.cancel_capture_io();
860            let reaped = already_reaped.or_else(|| {
861                let reap_result =
862                    poll_mutex_until(&self.child, deadline, Duration::from_millis(10), |state| {
863                        match state.as_mut() {
864                            Some(child) => child.child.try_wait(),
865                            None => Ok(None),
866                        }
867                    });
868                completed_reap_after_signal(reap_result)
869            });
870            if let Some(status) = reaped {
871                let code = exit_code(status);
872                self.set_returncode(code);
873                self.shared.emit_exited(pid, code);
874            }
875            public_symbols::rp_native_process_wait_for_capture_completion_with_deadline_public(
876                self, deadline,
877            );
878            Ok(())
879        }
880        #[cfg(windows)]
881        {
882            // Interrupt any pending capture `read()` in the per-stream reader
883            // threads so they fall out of their loops immediately. This is what
884            // makes the grandchild-pipe-orphan
885            // case (FastLED Bug B: uv.exe spawns a python.exe grandchild
886            // that inherits the pipe and outlives uv) wake up in
887            // microseconds instead of waiting for the bounded-drain
888            // safety-net deadline below.
889            #[cfg(any(windows, unix))]
890            self.cancel_capture_io();
891            // Synchronize with the per-stream reader threads so that by the
892            // time kill() returns, the capture queues have flipped from
893            // "blocked on read" to "closed" and downstream pollers (e.g.
894            // take_combined_line) observe EOS instead of timeout. Without
895            // this, callers that hit a wait()-timeout path see Python code
896            // raise TimeoutError, kill the child, then race the reader
897            // threads — a 10ms poll loop can miss the EOS flip entirely.
898            //
899            // The deadline remains a safety-net if the platform wake mechanism
900            // does not fire.
901            public_symbols::rp_native_process_wait_for_capture_completion_with_deadline_public(
902                self,
903                kill_drain_deadline(),
904            );
905            Ok(())
906        }
907    }
908
909    /// Terminate the child process.
910    ///
911    /// This currently uses the same hard-kill path as [`Self::kill`].
912    pub fn terminate(&self) -> Result<(), ProcessError> {
913        self.kill()
914    }
915
916    /// Send the OS-appropriate soft termination signal to the child's
917    /// process group (POSIX: SIGTERM to `-pid`; Windows: no soft path
918    /// implemented yet — returns Ok without doing anything so callers
919    /// can run the same code on both platforms and rely on the post-
920    /// grace hard kill).
921    ///
922    /// Requires `ProcessConfig.create_process_group=true` on POSIX so
923    /// that `-pid` resolves to the child's own group. With the default
924    /// `create_process_group=false`, the kill would walk back to the
925    /// caller's group; the method silently no-ops in that case to avoid
926    /// signaling the wrong tree.
927    ///
928    /// Used by the daemon-side pipe sessions (#130 M4 follow-up) so
929    /// that `TerminationOutcome::SoftExit` becomes meaningful on POSIX.
930    pub fn terminate_group_soft(&self) -> Result<(), ProcessError> {
931        #[cfg(unix)]
932        {
933            if !self.config.create_process_group {
934                return Ok(());
935            }
936            let pid = match self.pid() {
937                Some(p) => p as i32,
938                None => return Err(ProcessError::NotRunning),
939            };
940            let result = unsafe { libc::kill(-pid, libc::SIGTERM) };
941            if result != 0 {
942                let err = std::io::Error::last_os_error();
943                if err.raw_os_error() != Some(libc::ESRCH) {
944                    return Err(ProcessError::Io(err));
945                }
946            }
947            Ok(())
948        }
949        #[cfg(windows)]
950        {
951            if !self.config.create_process_group {
952                // GenerateConsoleCtrlEvent only routes to children
953                // spawned with CREATE_NEW_PROCESS_GROUP, and the
954                // event would otherwise hit the daemon's own group.
955                // No-op so the hard-kill schedule still wins.
956                return Ok(());
957            }
958            let pid = match self.pid() {
959                Some(p) => p,
960                None => return Err(ProcessError::NotRunning),
961            };
962            // GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT=1, pid).
963            // SAFETY: the FFI call is the standard Windows API; no
964            // borrowed Rust state is involved.
965            let ok = unsafe {
966                winapi::um::wincon::GenerateConsoleCtrlEvent(
967                    winapi::um::wincon::CTRL_BREAK_EVENT,
968                    pid,
969                )
970            };
971            if ok == 0 {
972                let err = std::io::Error::last_os_error();
973                // ERROR_INVALID_HANDLE means the child has already
974                // exited or has detached from the console — treat as
975                // success because the soft step's only goal is to
976                // give the child a chance to exit cleanly, and a
977                // dead/detached child does not need one.
978                if err.raw_os_error() != Some(6) {
979                    return Err(ProcessError::Io(err));
980                }
981            }
982            Ok(())
983        }
984    }
985
986    // Preserve a stable Rust frame here in release user dumps.
987    #[inline(never)]
988    /// Close the process wrapper by terminating the child when it is running.
989    pub fn close(&self) -> Result<(), ProcessError> {
990        public_symbols::rp_native_process_close_public(self)
991    }
992
993    fn close_impl(&self) -> Result<(), ProcessError> {
994        crate::rp_rust_debug_scope!("running_process::NativeProcess::close");
995        if self.child.lock().expect("child mutex poisoned").is_none() {
996            return Ok(());
997        }
998        if self.poll()?.is_none() {
999            self.kill()?;
1000        } else {
1001            self.finish_capture_drain();
1002        }
1003        Ok(())
1004    }
1005
1006    /// Return the child process id when the wrapper currently owns a child.
1007    pub fn pid(&self) -> Option<u32> {
1008        self.child
1009            .lock()
1010            .expect("child mutex poisoned")
1011            .as_ref()
1012            .map(|state| state.child.id())
1013    }
1014
1015    /// Return the cached exit code when the child has exited.
1016    pub fn returncode(&self) -> Option<i32> {
1017        let v = self.shared.returncode.load(Ordering::Acquire);
1018        if v == RETURNCODE_NOT_SET {
1019            None
1020        } else {
1021            Some(v as i32)
1022        }
1023    }
1024
1025    /// Return whether captured output is queued for one stream.
1026    pub fn has_pending_stream(&self, stream: StreamKind) -> bool {
1027        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
1028            return false;
1029        }
1030        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
1031        match stream {
1032            StreamKind::Stdout => !guard.stdout_queue.is_empty(),
1033            StreamKind::Stderr => !guard.stderr_queue.is_empty(),
1034        }
1035    }
1036
1037    /// Return whether captured combined output is queued.
1038    pub fn has_pending_combined(&self) -> bool {
1039        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
1040        !guard.combined_queue.is_empty()
1041    }
1042
1043    /// Drain and return all queued output for one stream.
1044    pub fn drain_stream(&self, stream: StreamKind) -> Vec<Vec<u8>> {
1045        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
1046            return Vec::new();
1047        }
1048        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1049        let queue = match stream {
1050            StreamKind::Stdout => &mut guard.stdout_queue,
1051            StreamKind::Stderr => &mut guard.stderr_queue,
1052        };
1053        queue.drain(..).collect()
1054    }
1055
1056    /// Drain and return all queued combined output events.
1057    pub fn drain_combined(&self) -> Vec<StreamEvent> {
1058        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1059        guard.combined_queue.drain(..).collect()
1060    }
1061
1062    /// Read the next captured chunk from one stream.
1063    ///
1064    /// Returns [`ReadStatus::Timeout`] when `timeout` elapses before output or
1065    /// EOF is observed.
1066    pub fn read_stream(
1067        &self,
1068        stream: StreamKind,
1069        timeout: Option<Duration>,
1070    ) -> ReadStatus<Vec<u8>> {
1071        let deadline = timeout.map(|limit| Instant::now() + limit);
1072        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1073
1074        loop {
1075            if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
1076                return ReadStatus::Eof;
1077            }
1078
1079            let queue = match stream {
1080                StreamKind::Stdout => &mut guard.stdout_queue,
1081                StreamKind::Stderr => &mut guard.stderr_queue,
1082            };
1083            if let Some(line) = queue.pop_front() {
1084                return ReadStatus::Line(line);
1085            }
1086
1087            let closed = match stream {
1088                StreamKind::Stdout => {
1089                    if self.config.stderr_mode == StderrMode::Stdout {
1090                        guard.stdout_closed && guard.stderr_closed
1091                    } else {
1092                        guard.stdout_closed
1093                    }
1094                }
1095                StreamKind::Stderr => guard.stderr_closed,
1096            };
1097            if closed {
1098                return ReadStatus::Eof;
1099            }
1100
1101            match deadline {
1102                Some(deadline) => {
1103                    let now = Instant::now();
1104                    if now >= deadline {
1105                        return ReadStatus::Timeout;
1106                    }
1107                    let wait = deadline.saturating_duration_since(now);
1108                    let result = self
1109                        .shared
1110                        .condvar
1111                        .wait_timeout(guard, wait)
1112                        .expect("queue mutex poisoned");
1113                    guard = result.0;
1114                    if result.1.timed_out() {
1115                        return ReadStatus::Timeout;
1116                    }
1117                }
1118                None => {
1119                    guard = self
1120                        .shared
1121                        .condvar
1122                        .wait(guard)
1123                        .expect("queue mutex poisoned");
1124                }
1125            }
1126        }
1127    }
1128
1129    // Preserve a stable Rust frame here in release user dumps.
1130    #[inline(never)]
1131    /// Read the next captured combined stream event.
1132    pub fn read_combined(&self, timeout: Option<Duration>) -> ReadStatus<StreamEvent> {
1133        public_symbols::rp_native_process_read_combined_public(self, timeout)
1134    }
1135
1136    fn read_combined_impl(&self, timeout: Option<Duration>) -> ReadStatus<StreamEvent> {
1137        crate::rp_rust_debug_scope!("running_process::NativeProcess::read_combined");
1138        let deadline = timeout.map(|limit| Instant::now() + limit);
1139        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1140
1141        loop {
1142            if let Some(event) = guard.combined_queue.pop_front() {
1143                return ReadStatus::Line(event);
1144            }
1145            if guard.stdout_closed && guard.stderr_closed {
1146                return ReadStatus::Eof;
1147            }
1148
1149            match deadline {
1150                Some(deadline) => {
1151                    let now = Instant::now();
1152                    if now >= deadline {
1153                        return ReadStatus::Timeout;
1154                    }
1155                    let wait = deadline.saturating_duration_since(now);
1156                    let result = self
1157                        .shared
1158                        .condvar
1159                        .wait_timeout(guard, wait)
1160                        .expect("queue mutex poisoned");
1161                    guard = result.0;
1162                    if result.1.timed_out() {
1163                        return ReadStatus::Timeout;
1164                    }
1165                }
1166                None => {
1167                    guard = self
1168                        .shared
1169                        .condvar
1170                        .wait(guard)
1171                        .expect("queue mutex poisoned");
1172                }
1173            }
1174        }
1175    }
1176
1177    /// Return the retained stdout history.
1178    pub fn captured_stdout(&self) -> Vec<Vec<u8>> {
1179        self.shared
1180            .queues
1181            .lock()
1182            .expect("queue mutex poisoned")
1183            .stdout_history
1184            .clone()
1185            .into_iter()
1186            .collect()
1187    }
1188
1189    fn captured_stdout_raw(&self) -> Vec<u8> {
1190        self.shared
1191            .queues
1192            .lock()
1193            .expect("queue mutex poisoned")
1194            .stdout_raw
1195            .clone()
1196    }
1197
1198    /// Return the retained stderr history.
1199    pub fn captured_stderr(&self) -> Vec<Vec<u8>> {
1200        if self.config.stderr_mode == StderrMode::Stdout {
1201            return Vec::new();
1202        }
1203        self.shared
1204            .queues
1205            .lock()
1206            .expect("queue mutex poisoned")
1207            .stderr_history
1208            .clone()
1209            .into_iter()
1210            .collect()
1211    }
1212
1213    fn captured_stderr_raw(&self) -> Vec<u8> {
1214        if self.config.stderr_mode == StderrMode::Stdout {
1215            return Vec::new();
1216        }
1217        self.shared
1218            .queues
1219            .lock()
1220            .expect("queue mutex poisoned")
1221            .stderr_raw
1222            .clone()
1223    }
1224
1225    /// Return the retained combined stdout/stderr event history.
1226    pub fn captured_combined(&self) -> Vec<StreamEvent> {
1227        self.shared
1228            .queues
1229            .lock()
1230            .expect("queue mutex poisoned")
1231            .combined_history
1232            .clone()
1233            .into_iter()
1234            .collect()
1235    }
1236
1237    /// Return the retained byte count for one captured stream.
1238    pub fn captured_stream_bytes(&self, stream: StreamKind) -> usize {
1239        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
1240            return 0;
1241        }
1242        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
1243        match stream {
1244            StreamKind::Stdout => guard.stdout_history_bytes,
1245            StreamKind::Stderr => guard.stderr_history_bytes,
1246        }
1247    }
1248
1249    /// Return the retained byte count for combined captured output.
1250    pub fn captured_combined_bytes(&self) -> usize {
1251        self.shared
1252            .queues
1253            .lock()
1254            .expect("queue mutex poisoned")
1255            .combined_history_bytes
1256    }
1257
1258    /// Clear retained output history for one stream and return freed bytes.
1259    pub fn clear_captured_stream(&self, stream: StreamKind) -> usize {
1260        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
1261            return 0;
1262        }
1263        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1264        match stream {
1265            StreamKind::Stdout => {
1266                let released = guard.stdout_history_bytes;
1267                guard.stdout_history.clear();
1268                guard.stdout_raw.clear();
1269                guard.stdout_history_bytes = 0;
1270                released
1271            }
1272            StreamKind::Stderr => {
1273                let released = guard.stderr_history_bytes;
1274                guard.stderr_history.clear();
1275                guard.stderr_raw.clear();
1276                guard.stderr_history_bytes = 0;
1277                released
1278            }
1279        }
1280    }
1281
1282    /// Clear retained combined output history and return freed bytes.
1283    pub fn clear_captured_combined(&self) -> usize {
1284        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1285        let released = guard.combined_history_bytes;
1286        guard.combined_history.clear();
1287        guard.combined_history_bytes = 0;
1288        released
1289    }
1290
1291    fn build_command(&self) -> Command {
1292        let command_override = self
1293            .command_override
1294            .lock()
1295            .expect("command override mutex poisoned")
1296            .take();
1297        let mut command = match command_override {
1298            Some(command) => command,
1299            None => {
1300                let mut command = match &self.config.command {
1301                    CommandSpec::Shell(command) => shell_command(command),
1302                    CommandSpec::Argv(argv) => {
1303                        let mut command = Command::new(&argv[0]);
1304                        if argv.len() > 1 {
1305                            command.args(&argv[1..]);
1306                        }
1307                        command
1308                    }
1309                };
1310                if let Some(cwd) = &self.config.cwd {
1311                    command.current_dir(cwd);
1312                }
1313                if let Some(env) = &self.config.env {
1314                    command.env_clear();
1315                    command.envs(env.iter().map(|(k, v)| (k, v)));
1316                }
1317                command
1318            }
1319        };
1320        #[cfg(windows)]
1321        {
1322            use std::os::windows::process::CommandExt;
1323
1324            // #584: defaults to CREATE_NO_WINDOW so a console child spawned
1325            // by the window-less daemon does not flash a console window,
1326            // while preserving the caller's console opinion, priority, and
1327            // CREATE_NEW_PROCESS_GROUP bits. Gated on the parent being
1328            // console-less (#622): a console-attached parent's child must
1329            // share its console so CTRL_C delivery keeps working.
1330            // See `windows_creation_flags`.
1331            let flags = windows_creation_flags(
1332                self.config.creationflags,
1333                self.config.create_process_group,
1334                self.config.nice,
1335                crate::windows::parent_has_console(),
1336            );
1337            if flags != 0 {
1338                command.creation_flags(flags);
1339            }
1340        }
1341        #[cfg(unix)]
1342        {
1343            let create_process_group = self.config.create_process_group;
1344            let nice = self.config.nice;
1345
1346            if create_process_group || nice.is_some() {
1347                use std::os::unix::process::CommandExt;
1348
1349                unsafe {
1350                    command.pre_exec(move || {
1351                        if create_process_group && libc::setpgid(0, 0) == -1 {
1352                            return Err(std::io::Error::last_os_error());
1353                        }
1354                        if let Some(nice) = nice {
1355                            let result = libc::setpriority(libc::PRIO_PROCESS, 0, nice);
1356                            if result == -1 {
1357                                return Err(std::io::Error::last_os_error());
1358                            }
1359                        }
1360                        Ok(())
1361                    });
1362                }
1363            }
1364        }
1365        command
1366    }
1367
1368    fn spawn_reader<R>(
1369        &self,
1370        pipe: R,
1371        source_stream: StreamKind,
1372        visible_stream: StreamKind,
1373        on_pipe_done: Box<dyn FnOnce() + Send>,
1374    ) where
1375        R: Read + Send + 'static,
1376    {
1377        let shared = Arc::clone(&self.shared);
1378        shared.active_capture_readers.fetch_add(1, Ordering::AcqRel);
1379        thread::spawn(move || {
1380            let mut reader = pipe;
1381            let mut chunk = vec![0_u8; 65536];
1382            let mut pending = Vec::new();
1383
1384            loop {
1385                match reader.read(&mut chunk) {
1386                    Ok(0) => break,
1387                    Ok(n) => {
1388                        if append_raw(&shared, visible_stream, &chunk[..n]) {
1389                            let lines = feed_chunk(&mut pending, &chunk[..n]);
1390                            emit_lines(&shared, visible_stream, lines);
1391                        } else {
1392                            pending.clear();
1393                        }
1394                    }
1395                    Err(_) => break,
1396                }
1397            }
1398
1399            if !pending.is_empty() && !shared.capture_overflowed.load(Ordering::Acquire) {
1400                emit_lines(&shared, visible_stream, vec![std::mem::take(&mut pending)]);
1401            }
1402
1403            // Clear the parent-side pipe-handle slot under its mutex
1404            // before dropping the reader. After this returns,
1405            // `kill_impl` can no longer try to `CancelIoEx` on us, so
1406            // it's safe for `reader`'s drop to close the HANDLE.
1407            on_pipe_done();
1408            drop(reader);
1409
1410            let mut guard = shared.queues.lock().expect("queue mutex poisoned");
1411            match source_stream {
1412                StreamKind::Stdout => guard.stdout_closed = true,
1413                StreamKind::Stderr => guard.stderr_closed = true,
1414            }
1415            shared.active_capture_readers.fetch_sub(1, Ordering::AcqRel);
1416            shared.condvar.notify_all();
1417        });
1418    }
1419
1420    #[cfg(unix)]
1421    fn prepare_unix_capture_reader<R: Read + AsRawFd>(
1422        reader: R,
1423    ) -> std::io::Result<(UnixCancelableReader<R>, UnixStream)> {
1424        set_nonblocking(reader.as_raw_fd())?;
1425        let (wake_reader, wake_writer) = UnixStream::pair()?;
1426        wake_writer.set_nonblocking(true)?;
1427        Ok((
1428            UnixCancelableReader {
1429                reader,
1430                wake_reader,
1431            },
1432            wake_writer,
1433        ))
1434    }
1435
1436    #[cfg(windows)]
1437    fn pipe_done_callback(&self, stream: StreamKind) -> Box<dyn FnOnce() + Send> {
1438        let handles = Arc::clone(&self.capture_pipe_handles);
1439        Box::new(move || {
1440            let mut guard = handles.lock().expect("capture pipe handles mutex poisoned");
1441            match stream {
1442                StreamKind::Stdout => guard.stdout = None,
1443                StreamKind::Stderr => guard.stderr = None,
1444            }
1445        })
1446    }
1447
1448    #[cfg(unix)]
1449    fn pipe_done_callback(&self, stream: StreamKind) -> Box<dyn FnOnce() + Send> {
1450        let wakers = Arc::clone(&self.capture_wakers);
1451        Box::new(move || {
1452            let mut guard = wakers.lock().expect("capture wakers mutex poisoned");
1453            match stream {
1454                StreamKind::Stdout => guard.stdout = None,
1455                StreamKind::Stderr => guard.stderr = None,
1456            }
1457        })
1458    }
1459
1460    #[cfg(not(any(windows, unix)))]
1461    fn pipe_done_callback(&self, _stream: StreamKind) -> Box<dyn FnOnce() + Send> {
1462        Box::new(|| {})
1463    }
1464
1465    /// Cancel pending capture reads so reader threads return immediately.
1466    /// Used by `kill_impl` to break the grandchild-orphan deadlock without
1467    /// waiting on `wait_for_capture_completion_with_deadline`'s safety-net.
1468    #[cfg(windows)]
1469    fn cancel_capture_io(&self) {
1470        crate::rp_rust_debug_scope!("running_process::NativeProcess::cancel_capture_io");
1471        cancel_capture_pipe_io(&self.capture_pipe_handles);
1472    }
1473
1474    #[cfg(unix)]
1475    fn cancel_capture_io(&self) {
1476        crate::rp_rust_debug_scope!("running_process::NativeProcess::cancel_capture_io");
1477        cancel_capture_pipe_io(&self.capture_wakers);
1478    }
1479
1480    fn set_returncode(&self, code: i32) {
1481        self.shared.returncode.store(code as i64, Ordering::Release);
1482        self.shared.condvar.notify_all();
1483    }
1484
1485    /// Bounded capture drain for the natural-exit and `close` paths
1486    /// (issue #590, cluster A). Waits at most `kill_drain_deadline` for the
1487    /// reader threads to flip the closed flags, force-setting them on
1488    /// timeout so `wait()`/`close()` return in bounded time instead of
1489    /// wedging in the previously-unbounded `wait_for_capture_completion`.
1490    /// Unlike `kill_impl` the reader is not cancelled up front — a
1491    /// short-lived grandchild's output is allowed to drain within the
1492    /// grace window — but if the window elapses with the pipe still held
1493    /// open the reader is cancelled to release the leaked thread.
1494    fn finish_capture_drain(&self) {
1495        self.finish_capture_drain_with_deadline(kill_drain_deadline());
1496    }
1497
1498    fn finish_capture_drain_with_deadline(&self, deadline: Instant) {
1499        let drained = self.wait_for_capture_completion_with_deadline_impl(deadline);
1500        #[cfg(any(windows, unix))]
1501        if !drained {
1502            self.cancel_capture_io();
1503        }
1504        #[cfg(not(any(windows, unix)))]
1505        let _ = drained;
1506    }
1507
1508    /// Returns `true` if the reader threads flipped both closed flags on their
1509    /// own before `deadline`, `false` if the deadline forced completion.
1510    fn wait_for_capture_completion_with_deadline_impl(&self, deadline: Instant) -> bool {
1511        crate::rp_rust_debug_scope!(
1512            "running_process::NativeProcess::wait_for_capture_completion_with_deadline"
1513        );
1514        if !self.config.capture {
1515            return true;
1516        }
1517        finalize_capture_completion(&self.shared, deadline)
1518    }
1519
1520    fn wait_for_capture_readers_with_deadline(&self, deadline: Instant) -> bool {
1521        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1522        while self.shared.active_capture_readers.load(Ordering::Acquire) != 0 {
1523            let now = Instant::now();
1524            if now >= deadline {
1525                return false;
1526            }
1527            let (next_guard, result) = self
1528                .shared
1529                .condvar
1530                .wait_timeout(guard, deadline - now)
1531                .expect("queue mutex poisoned");
1532            guard = next_guard;
1533            if result.timed_out() && self.shared.active_capture_readers.load(Ordering::Acquire) != 0
1534            {
1535                return false;
1536            }
1537        }
1538        true
1539    }
1540}
1541
1542/// Cancel any pending blocking `read()` on the parent-side capture pipes
1543/// so the reader threads' `read()` calls return `ERROR_OPERATION_ABORTED`
1544/// immediately. Shared by `kill_impl`, `poll`, and the natural-exit
1545/// waiter thread (issue #590) — anywhere the child is observed to exit
1546/// while a grandchild may still hold the pipe open.
1547#[cfg(windows)]
1548fn cancel_capture_pipe_io(handles: &Mutex<CapturePipeHandles>) {
1549    use winapi::shared::ntdef::HANDLE;
1550    use winapi::um::ioapiset::CancelIoEx;
1551    let guard = handles.lock().expect("capture pipe handles mutex poisoned");
1552    if let Some(h) = guard.stdout {
1553        // SAFETY: the slot is `Some` only while the owning reader thread
1554        // still holds the `ChildStdout`, so the HANDLE is valid for the
1555        // duration of this call. The reader is blocked in `lock()` on the
1556        // same mutex if it's racing us toward exit, so it cannot drop the
1557        // pipe and close the HANDLE until we return.
1558        unsafe {
1559            CancelIoEx(h as HANDLE, std::ptr::null_mut());
1560        }
1561    }
1562    if let Some(h) = guard.stderr {
1563        unsafe {
1564            CancelIoEx(h as HANDLE, std::ptr::null_mut());
1565        }
1566    }
1567}
1568
1569#[cfg(unix)]
1570fn cancel_capture_pipe_io(wakers: &Mutex<UnixCaptureWakers>) {
1571    use std::os::fd::AsRawFd;
1572
1573    let guard = wakers.lock().expect("capture wakers mutex poisoned");
1574    let byte = [1_u8; 1];
1575    for writer in [&guard.stdout, &guard.stderr].into_iter().flatten() {
1576        // The wake writers are nonblocking and receive at most one byte per
1577        // cancellation path. EAGAIN means a previous wake byte is already
1578        // pending, which is equally sufficient to release poll().
1579        let _ = unsafe { libc::write(writer.as_raw_fd(), byte.as_ptr().cast(), byte.len()) };
1580    }
1581}
1582
1583/// Wait until both capture streams report closed or `deadline` elapses.
1584/// On deadline, force-set the closed flags (and notify all waiters) so
1585/// downstream pollers observe EOF instead of blocking forever. Returns
1586/// `true` if the reader threads flipped the flags on their own, `false`
1587/// if the deadline forced them. A reader thread that later unblocks and
1588/// re-sets `closed = true` is a harmless no-op.
1589fn finalize_capture_completion(shared: &SharedState, deadline: Instant) -> bool {
1590    let mut guard = shared.queues.lock().expect("queue mutex poisoned");
1591    while !(guard.stdout_closed && guard.stderr_closed) {
1592        let now = Instant::now();
1593        if now >= deadline {
1594            guard.stdout_closed = true;
1595            guard.stderr_closed = true;
1596            shared.condvar.notify_all();
1597            return false;
1598        }
1599        let (next_guard, result) = shared
1600            .condvar
1601            .wait_timeout(guard, deadline - now)
1602            .expect("queue mutex poisoned");
1603        guard = next_guard;
1604        if result.timed_out() && !(guard.stdout_closed && guard.stderr_closed) {
1605            guard.stdout_closed = true;
1606            guard.stderr_closed = true;
1607            shared.condvar.notify_all();
1608            return false;
1609        }
1610    }
1611    true
1612}
1613
1614fn emit_lines(shared: &Arc<SharedState>, stream: StreamKind, lines: Vec<Vec<u8>>) {
1615    if lines.is_empty() || shared.capture_overflowed.load(Ordering::Acquire) {
1616        return;
1617    }
1618    let mut guard = shared.queues.lock().expect("queue mutex poisoned");
1619    if shared.capture_overflowed.load(Ordering::Acquire) {
1620        return;
1621    }
1622    for line in lines {
1623        let line_len = line.len();
1624        match stream {
1625            StreamKind::Stdout => {
1626                guard.stdout_history_bytes += line_len;
1627                guard.stdout_history.push_back(line.clone());
1628                guard.stdout_queue.push_back(line.clone());
1629            }
1630            StreamKind::Stderr => {
1631                guard.stderr_history_bytes += line_len;
1632                guard.stderr_history.push_back(line.clone());
1633                guard.stderr_queue.push_back(line.clone());
1634            }
1635        }
1636        let event = StreamEvent { stream, line };
1637        guard.combined_history_bytes += line_len;
1638        guard.combined_history.push_back(event.clone());
1639        guard.combined_queue.push_back(event);
1640    }
1641    shared.condvar.notify_all();
1642}
1643
1644fn append_raw(shared: &Arc<SharedState>, stream: StreamKind, chunk: &[u8]) -> bool {
1645    if chunk.is_empty() {
1646        return true;
1647    }
1648    let mut guard = shared.queues.lock().expect("queue mutex poisoned");
1649    let accepted = match shared.capture_limit {
1650        Some(limit) => {
1651            let retained = guard
1652                .stdout_raw
1653                .len()
1654                .saturating_add(guard.stderr_raw.len());
1655            chunk.len().min(limit.saturating_sub(retained))
1656        }
1657        None => chunk.len(),
1658    };
1659    match stream {
1660        StreamKind::Stdout => guard.stdout_raw.extend_from_slice(&chunk[..accepted]),
1661        StreamKind::Stderr => guard.stderr_raw.extend_from_slice(&chunk[..accepted]),
1662    }
1663    if accepted != chunk.len() {
1664        shared.capture_overflowed.store(true, Ordering::Release);
1665        false
1666    } else {
1667        true
1668    }
1669}
1670
1671/// Run a command to completion while concurrently draining stdout and stderr.
1672///
1673/// The helper forces capture on regardless of `config.capture`, returns raw
1674/// stdout/stderr bytes, and kills the child before returning
1675/// [`ProcessError::Timeout`] when `timeout` elapses.
1676pub fn run_command(
1677    mut config: ProcessConfig,
1678    timeout: Option<Duration>,
1679) -> Result<RunOutput, ProcessError> {
1680    config.capture = true;
1681    let process = NativeProcess::new(config);
1682    process.start()?;
1683
1684    let exit_code = match process.wait(timeout) {
1685        Ok(code) => code,
1686        Err(ProcessError::Timeout) => {
1687            match process.kill() {
1688                Ok(()) | Err(ProcessError::NotRunning) => {}
1689                Err(error) => return Err(error),
1690            }
1691            return Err(ProcessError::Timeout);
1692        }
1693        Err(error) => return Err(error),
1694    };
1695
1696    Ok(RunOutput {
1697        stdout: process.captured_stdout_raw(),
1698        stderr: process.captured_stderr_raw(),
1699        exit_code,
1700    })
1701}
1702
1703struct BoundedRunCleanup<'a> {
1704    process: &'a NativeProcess,
1705    armed: bool,
1706}
1707
1708impl BoundedRunCleanup<'_> {
1709    fn disarm(&mut self) {
1710        self.armed = false;
1711    }
1712}
1713
1714impl Drop for BoundedRunCleanup<'_> {
1715    fn drop(&mut self) {
1716        if !self.armed {
1717            return;
1718        }
1719
1720        // Error paths must not strand either the process tree or its capture
1721        // readers. Cancel first so even a failing/redundant kill cannot leave
1722        // threads blocked on pipes inherited by an escaped descendant.
1723        #[cfg(any(windows, unix))]
1724        self.process.cancel_capture_io();
1725        let _ = self.process.poll();
1726        if self.process.returncode().is_none() {
1727            let _ = self.process.kill();
1728        } else {
1729            self.process.finish_capture_drain();
1730        }
1731        let _ = self
1732            .process
1733            .wait_for_capture_readers_with_deadline(kill_drain_deadline());
1734    }
1735}
1736
1737fn run_native_process_bounded(
1738    process: NativeProcess,
1739    timeout: Option<Duration>,
1740    output_limit: usize,
1741) -> Result<RunOutput, ProcessError> {
1742    process.start()?;
1743    let mut cleanup = BoundedRunCleanup {
1744        process: &process,
1745        armed: true,
1746    };
1747    let started = Instant::now();
1748
1749    let exit_code = loop {
1750        if process.shared.capture_overflowed.load(Ordering::Acquire) {
1751            return Err(ProcessError::OutputLimitExceeded {
1752                limit: output_limit,
1753            });
1754        }
1755        if let Some(code) = process.poll()? {
1756            process.finish_capture_drain();
1757            break code;
1758        }
1759        if timeout.is_some_and(|limit| started.elapsed() >= limit) {
1760            return Err(ProcessError::Timeout);
1761        }
1762        thread::sleep(Duration::from_millis(5));
1763    };
1764
1765    if !process.wait_for_capture_readers_with_deadline(kill_drain_deadline()) {
1766        return Err(ProcessError::Io(std::io::Error::new(
1767            std::io::ErrorKind::TimedOut,
1768            "capture readers did not stop after process exit",
1769        )));
1770    }
1771    if process.shared.capture_overflowed.load(Ordering::Acquire) {
1772        return Err(ProcessError::OutputLimitExceeded {
1773            limit: output_limit,
1774        });
1775    }
1776
1777    let output = RunOutput {
1778        stdout: process.captured_stdout_raw(),
1779        stderr: process.captured_stderr_raw(),
1780        exit_code,
1781    };
1782    cleanup.disarm();
1783    Ok(output)
1784}
1785
1786/// Run a command with an aggregate stdout/stderr capture limit.
1787///
1788/// Once `output_limit` bytes have been retained, further output is drained
1789/// without allocation, the contained process is terminated, and
1790/// [`ProcessError::OutputLimitExceeded`] is returned. Timeout and overflow
1791/// paths wait for the cancelable capture readers to actually exit before
1792/// returning, including when a descendant escaped the process group while
1793/// retaining a pipe.
1794pub fn run_command_bounded(
1795    mut config: ProcessConfig,
1796    timeout: Option<Duration>,
1797    output_limit: usize,
1798) -> Result<RunOutput, ProcessError> {
1799    config.capture = true;
1800    config.create_process_group = true;
1801    let process = NativeProcess::new_with_capture_limit(config, output_limit);
1802    run_native_process_bounded(process, timeout, output_limit)
1803}
1804
1805/// Run an existing [`std::process::Command`] with bounded capture.
1806///
1807/// Unlike [`run_command_bounded`], this entrypoint preserves non-UTF-8
1808/// program paths, arguments, environment keys/values, and every other command
1809/// setting exactly. Running-process still owns containment, console policy,
1810/// timeout cleanup, and stdout/stderr capture.
1811pub fn run_std_command_bounded(
1812    command: Command,
1813    timeout: Option<Duration>,
1814    output_limit: usize,
1815) -> Result<RunOutput, ProcessError> {
1816    let config = ProcessConfig {
1817        // The command override is consumed before this placeholder can be
1818        // inspected. Keeping ProcessConfig internal policy in one shape avoids
1819        // a second process-launch implementation.
1820        command: CommandSpec::Argv(vec!["running-process-command-override".to_string()]),
1821        cwd: None,
1822        env: None,
1823        capture: true,
1824        stderr_mode: StderrMode::Pipe,
1825        creationflags: None,
1826        create_process_group: true,
1827        stdin_mode: StdinMode::Null,
1828        nice: None,
1829    };
1830    let process = NativeProcess::new_with_command_capture_limit(command, config, output_limit);
1831    run_native_process_bounded(process, timeout, output_limit)
1832}
1833
1834pub(crate) fn shell_command(command: &str) -> Command {
1835    #[cfg(windows)]
1836    {
1837        use std::os::windows::process::CommandExt;
1838
1839        let mut cmd = Command::new("cmd");
1840        cmd.raw_arg("/D /S /C \"");
1841        cmd.raw_arg(command);
1842        cmd.raw_arg("\"");
1843        cmd
1844    }
1845    #[cfg(not(windows))]
1846    {
1847        let mut cmd = Command::new("sh");
1848        cmd.arg("-lc").arg(command);
1849        cmd
1850    }
1851}
1852
1853#[cfg(test)]
1854mod tests;