Skip to main content

running_process/
lib.rs

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