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