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