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