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