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