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