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        thread::spawn(move || {
415            loop {
416                if shared.returncode.load(Ordering::Acquire) != RETURNCODE_NOT_SET {
417                    return;
418                }
419                {
420                    let mut guard = child.lock().expect("child mutex poisoned");
421                    if let Some(child_state) = guard.as_mut() {
422                        let pid = child_state.child.id();
423                        match child_state.child.try_wait() {
424                            Ok(Some(status)) => {
425                                let code = exit_code(status);
426                                shared.returncode.store(code as i64, Ordering::Release);
427                                // Phase 1 of #221: lifecycle `exited`. Emit
428                                // before notifying waiters and is guarded so
429                                // only the first exit-observer fires.
430                                shared.emit_exited(pid, code);
431                                shared.condvar.notify_all();
432                                return;
433                            }
434                            Ok(None) => {}
435                            Err(_) => return,
436                        }
437                    } else {
438                        return;
439                    }
440                }
441                // #199: intentional — capture thread polling for
442                // child-exit. `try_wait` is non-blocking by design;
443                // we can't block here because the thread also drains
444                // pipe state alongside the exit check. 10ms keeps the
445                // CPU cost negligible while staying responsive.
446                thread::sleep(Duration::from_millis(10));
447            }
448        });
449    }
450
451    /// Write bytes to the child's stdin and then close stdin.
452    pub fn write_stdin(&self, data: &[u8]) -> Result<(), ProcessError> {
453        let mut guard = self.child.lock().expect("child mutex poisoned");
454        let child = &mut guard.as_mut().ok_or(ProcessError::NotRunning)?.child;
455        let stdin = child.stdin.as_mut().ok_or(ProcessError::StdinUnavailable)?;
456        use std::io::Write;
457        stdin.write_all(data).map_err(ProcessError::Io)?;
458        stdin.flush().map_err(ProcessError::Io)?;
459        drop(child.stdin.take());
460        Ok(())
461    }
462
463    /// Write to the child's stdin without closing it afterwards, so the
464    /// caller can issue additional writes. Used by interactive
465    /// pipe-backed sessions (#130 milestone 3) where the daemon keeps
466    /// stdin open across multiple client input frames.
467    pub fn write_stdin_streaming(&self, data: &[u8]) -> Result<(), ProcessError> {
468        let mut guard = self.child.lock().expect("child mutex poisoned");
469        let child = &mut guard.as_mut().ok_or(ProcessError::NotRunning)?.child;
470        let stdin = child.stdin.as_mut().ok_or(ProcessError::StdinUnavailable)?;
471        use std::io::Write;
472        stdin.write_all(data).map_err(ProcessError::Io)?;
473        stdin.flush().map_err(ProcessError::Io)?;
474        Ok(())
475    }
476
477    /// Explicitly close the child's stdin (signals EOF to the child).
478    /// Idempotent: returns Ok if stdin was already closed.
479    pub fn close_stdin(&self) -> Result<(), ProcessError> {
480        let mut guard = self.child.lock().expect("child mutex poisoned");
481        let child = &mut guard.as_mut().ok_or(ProcessError::NotRunning)?.child;
482        drop(child.stdin.take());
483        Ok(())
484    }
485
486    /// Check whether the child has exited without blocking.
487    ///
488    /// Returns `Ok(None)` while the process is still running.
489    pub fn poll(&self) -> Result<Option<i32>, ProcessError> {
490        // Fast path: check atomic set by background waiter thread.
491        if let Some(code) = self.returncode() {
492            return Ok(Some(code));
493        }
494        let mut guard = self.child.lock().expect("child mutex poisoned");
495        let Some(child_state) = guard.as_mut() else {
496            return Ok(self.returncode());
497        };
498        let pid = child_state.child.id();
499        let child = &mut child_state.child;
500        let status = child.try_wait().map_err(ProcessError::Io)?;
501        if let Some(status) = status {
502            let code = exit_code(status);
503            self.set_returncode(code);
504            self.shared.emit_exited(pid, code);
505            return Ok(Some(code));
506        }
507        Ok(None)
508    }
509
510    // Preserve a stable Rust frame here in release user dumps.
511    #[inline(never)]
512    /// Wait for the child to exit.
513    ///
514    /// When `timeout` is `Some`, returns [`ProcessError::Timeout`] if the
515    /// child does not exit before the duration elapses.
516    pub fn wait(&self, timeout: Option<Duration>) -> Result<i32, ProcessError> {
517        public_symbols::rp_native_process_wait_public(self, timeout)
518    }
519
520    fn wait_impl(&self, timeout: Option<Duration>) -> Result<i32, ProcessError> {
521        crate::rp_rust_debug_scope!("running_process::NativeProcess::wait");
522        if self.child.lock().expect("child mutex poisoned").is_none() {
523            return self.returncode().ok_or(ProcessError::NotRunning);
524        }
525        // Fast path: already exited.
526        if let Some(code) = self.returncode() {
527            public_symbols::rp_native_process_wait_for_capture_completion_public(self);
528            return Ok(code);
529        }
530        let start = Instant::now();
531        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
532        loop {
533            // Check returncode (set by exit-waiter thread via atomic + condvar).
534            let rc = self.shared.returncode.load(Ordering::Acquire);
535            if rc != RETURNCODE_NOT_SET {
536                drop(guard);
537                let code = rc as i32;
538                public_symbols::rp_native_process_wait_for_capture_completion_public(self);
539                return Ok(code);
540            }
541            if let Some(limit) = timeout {
542                let elapsed = start.elapsed();
543                if elapsed >= limit {
544                    return Err(ProcessError::Timeout);
545                }
546                let remaining = limit - elapsed;
547                // Wait on condvar with timeout, capped at 50ms to recheck.
548                let wait_time = remaining.min(Duration::from_millis(50));
549                guard = self
550                    .shared
551                    .condvar
552                    .wait_timeout(guard, wait_time)
553                    .expect("queue mutex poisoned")
554                    .0;
555            } else {
556                // Wait on condvar with periodic recheck.
557                guard = self
558                    .shared
559                    .condvar
560                    .wait_timeout(guard, Duration::from_millis(50))
561                    .expect("queue mutex poisoned")
562                    .0;
563            }
564        }
565    }
566
567    // Preserve a stable Rust frame here in release user dumps.
568    #[inline(never)]
569    /// Forcefully terminate the child process.
570    pub fn kill(&self) -> Result<(), ProcessError> {
571        public_symbols::rp_native_process_kill_public(self)
572    }
573
574    fn kill_impl(&self) -> Result<(), ProcessError> {
575        crate::rp_rust_debug_scope!("running_process::NativeProcess::kill");
576        {
577            let mut guard = self.child.lock().expect("child mutex poisoned");
578            let child = &mut guard.as_mut().ok_or(ProcessError::NotRunning)?.child;
579            let pid = child.id();
580            child.kill().map_err(ProcessError::Io)?;
581            let status = child.wait().map_err(ProcessError::Io)?;
582            let code = exit_code(status);
583            self.set_returncode(code);
584            // Phase 1 of #221: a killed child still produces a lifecycle
585            // `exited` event (guarded against double-emit by the waiter).
586            self.shared.emit_exited(pid, code);
587        }
588        // On Windows, interrupt any pending blocking `read()` in the
589        // per-stream reader threads so they fall out of their loops
590        // immediately. This is what makes the grandchild-pipe-orphan
591        // case (FastLED Bug B: uv.exe spawns a python.exe grandchild
592        // that inherits the pipe and outlives uv) wake up in
593        // microseconds instead of waiting for the bounded-drain
594        // safety-net deadline below.
595        #[cfg(windows)]
596        self.cancel_capture_io();
597        // Synchronize with the per-stream reader threads so that by the
598        // time kill() returns, the capture queues have flipped from
599        // "blocked on read" to "closed" and downstream pollers (e.g.
600        // take_combined_line) observe EOS instead of timeout. Without
601        // this, callers that hit a wait()-timeout path see Python code
602        // raise TimeoutError, kill the child, then race the reader
603        // threads — a 10ms poll loop can miss the EOS flip entirely.
604        //
605        // The deadline is the safety-net: on Windows `CancelIoEx`
606        // above almost always wakes the readers first; on POSIX, and
607        // in any pathological Windows case where `CancelIoEx` doesn't
608        // fire, the deadline guarantees `kill()` still returns.
609        public_symbols::rp_native_process_wait_for_capture_completion_with_deadline_public(
610            self,
611            kill_drain_deadline(),
612        );
613        Ok(())
614    }
615
616    /// Terminate the child process.
617    ///
618    /// This currently uses the same hard-kill path as [`Self::kill`].
619    pub fn terminate(&self) -> Result<(), ProcessError> {
620        self.kill()
621    }
622
623    /// Send the OS-appropriate soft termination signal to the child's
624    /// process group (POSIX: SIGTERM to `-pid`; Windows: no soft path
625    /// implemented yet — returns Ok without doing anything so callers
626    /// can run the same code on both platforms and rely on the post-
627    /// grace hard kill).
628    ///
629    /// Requires `ProcessConfig.create_process_group=true` on POSIX so
630    /// that `-pid` resolves to the child's own group. With the default
631    /// `create_process_group=false`, the kill would walk back to the
632    /// caller's group; the method silently no-ops in that case to avoid
633    /// signaling the wrong tree.
634    ///
635    /// Used by the daemon-side pipe sessions (#130 M4 follow-up) so
636    /// that `TerminationOutcome::SoftExit` becomes meaningful on POSIX.
637    pub fn terminate_group_soft(&self) -> Result<(), ProcessError> {
638        #[cfg(unix)]
639        {
640            if !self.config.create_process_group {
641                return Ok(());
642            }
643            let pid = match self.pid() {
644                Some(p) => p as i32,
645                None => return Err(ProcessError::NotRunning),
646            };
647            let result = unsafe { libc::kill(-pid, libc::SIGTERM) };
648            if result != 0 {
649                let err = std::io::Error::last_os_error();
650                if err.raw_os_error() != Some(libc::ESRCH) {
651                    return Err(ProcessError::Io(err));
652                }
653            }
654            Ok(())
655        }
656        #[cfg(windows)]
657        {
658            if !self.config.create_process_group {
659                // GenerateConsoleCtrlEvent only routes to children
660                // spawned with CREATE_NEW_PROCESS_GROUP, and the
661                // event would otherwise hit the daemon's own group.
662                // No-op so the hard-kill schedule still wins.
663                return Ok(());
664            }
665            let pid = match self.pid() {
666                Some(p) => p,
667                None => return Err(ProcessError::NotRunning),
668            };
669            // GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT=1, pid).
670            // SAFETY: the FFI call is the standard Windows API; no
671            // borrowed Rust state is involved.
672            let ok = unsafe {
673                winapi::um::wincon::GenerateConsoleCtrlEvent(
674                    winapi::um::wincon::CTRL_BREAK_EVENT,
675                    pid,
676                )
677            };
678            if ok == 0 {
679                let err = std::io::Error::last_os_error();
680                // ERROR_INVALID_HANDLE means the child has already
681                // exited or has detached from the console — treat as
682                // success because the soft step's only goal is to
683                // give the child a chance to exit cleanly, and a
684                // dead/detached child does not need one.
685                if err.raw_os_error() != Some(6) {
686                    return Err(ProcessError::Io(err));
687                }
688            }
689            Ok(())
690        }
691    }
692
693    // Preserve a stable Rust frame here in release user dumps.
694    #[inline(never)]
695    /// Close the process wrapper by terminating the child when it is running.
696    pub fn close(&self) -> Result<(), ProcessError> {
697        public_symbols::rp_native_process_close_public(self)
698    }
699
700    fn close_impl(&self) -> Result<(), ProcessError> {
701        crate::rp_rust_debug_scope!("running_process::NativeProcess::close");
702        if self.child.lock().expect("child mutex poisoned").is_none() {
703            return Ok(());
704        }
705        if self.poll()?.is_none() {
706            self.kill()?;
707        } else {
708            public_symbols::rp_native_process_wait_for_capture_completion_public(self);
709        }
710        Ok(())
711    }
712
713    /// Return the child process id when the wrapper currently owns a child.
714    pub fn pid(&self) -> Option<u32> {
715        self.child
716            .lock()
717            .expect("child mutex poisoned")
718            .as_ref()
719            .map(|state| state.child.id())
720    }
721
722    /// Return the cached exit code when the child has exited.
723    pub fn returncode(&self) -> Option<i32> {
724        let v = self.shared.returncode.load(Ordering::Acquire);
725        if v == RETURNCODE_NOT_SET {
726            None
727        } else {
728            Some(v as i32)
729        }
730    }
731
732    /// Return whether captured output is queued for one stream.
733    pub fn has_pending_stream(&self, stream: StreamKind) -> bool {
734        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
735            return false;
736        }
737        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
738        match stream {
739            StreamKind::Stdout => !guard.stdout_queue.is_empty(),
740            StreamKind::Stderr => !guard.stderr_queue.is_empty(),
741        }
742    }
743
744    /// Return whether captured combined output is queued.
745    pub fn has_pending_combined(&self) -> bool {
746        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
747        !guard.combined_queue.is_empty()
748    }
749
750    /// Drain and return all queued output for one stream.
751    pub fn drain_stream(&self, stream: StreamKind) -> Vec<Vec<u8>> {
752        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
753            return Vec::new();
754        }
755        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
756        let queue = match stream {
757            StreamKind::Stdout => &mut guard.stdout_queue,
758            StreamKind::Stderr => &mut guard.stderr_queue,
759        };
760        queue.drain(..).collect()
761    }
762
763    /// Drain and return all queued combined output events.
764    pub fn drain_combined(&self) -> Vec<StreamEvent> {
765        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
766        guard.combined_queue.drain(..).collect()
767    }
768
769    /// Read the next captured chunk from one stream.
770    ///
771    /// Returns [`ReadStatus::Timeout`] when `timeout` elapses before output or
772    /// EOF is observed.
773    pub fn read_stream(
774        &self,
775        stream: StreamKind,
776        timeout: Option<Duration>,
777    ) -> ReadStatus<Vec<u8>> {
778        let deadline = timeout.map(|limit| Instant::now() + limit);
779        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
780
781        loop {
782            if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
783                return ReadStatus::Eof;
784            }
785
786            let queue = match stream {
787                StreamKind::Stdout => &mut guard.stdout_queue,
788                StreamKind::Stderr => &mut guard.stderr_queue,
789            };
790            if let Some(line) = queue.pop_front() {
791                return ReadStatus::Line(line);
792            }
793
794            let closed = match stream {
795                StreamKind::Stdout => {
796                    if self.config.stderr_mode == StderrMode::Stdout {
797                        guard.stdout_closed && guard.stderr_closed
798                    } else {
799                        guard.stdout_closed
800                    }
801                }
802                StreamKind::Stderr => guard.stderr_closed,
803            };
804            if closed {
805                return ReadStatus::Eof;
806            }
807
808            match deadline {
809                Some(deadline) => {
810                    let now = Instant::now();
811                    if now >= deadline {
812                        return ReadStatus::Timeout;
813                    }
814                    let wait = deadline.saturating_duration_since(now);
815                    let result = self
816                        .shared
817                        .condvar
818                        .wait_timeout(guard, wait)
819                        .expect("queue mutex poisoned");
820                    guard = result.0;
821                    if result.1.timed_out() {
822                        return ReadStatus::Timeout;
823                    }
824                }
825                None => {
826                    guard = self
827                        .shared
828                        .condvar
829                        .wait(guard)
830                        .expect("queue mutex poisoned");
831                }
832            }
833        }
834    }
835
836    // Preserve a stable Rust frame here in release user dumps.
837    #[inline(never)]
838    /// Read the next captured combined stream event.
839    pub fn read_combined(&self, timeout: Option<Duration>) -> ReadStatus<StreamEvent> {
840        public_symbols::rp_native_process_read_combined_public(self, timeout)
841    }
842
843    fn read_combined_impl(&self, timeout: Option<Duration>) -> ReadStatus<StreamEvent> {
844        crate::rp_rust_debug_scope!("running_process::NativeProcess::read_combined");
845        let deadline = timeout.map(|limit| Instant::now() + limit);
846        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
847
848        loop {
849            if let Some(event) = guard.combined_queue.pop_front() {
850                return ReadStatus::Line(event);
851            }
852            if guard.stdout_closed && guard.stderr_closed {
853                return ReadStatus::Eof;
854            }
855
856            match deadline {
857                Some(deadline) => {
858                    let now = Instant::now();
859                    if now >= deadline {
860                        return ReadStatus::Timeout;
861                    }
862                    let wait = deadline.saturating_duration_since(now);
863                    let result = self
864                        .shared
865                        .condvar
866                        .wait_timeout(guard, wait)
867                        .expect("queue mutex poisoned");
868                    guard = result.0;
869                    if result.1.timed_out() {
870                        return ReadStatus::Timeout;
871                    }
872                }
873                None => {
874                    guard = self
875                        .shared
876                        .condvar
877                        .wait(guard)
878                        .expect("queue mutex poisoned");
879                }
880            }
881        }
882    }
883
884    /// Return the retained stdout history.
885    pub fn captured_stdout(&self) -> Vec<Vec<u8>> {
886        self.shared
887            .queues
888            .lock()
889            .expect("queue mutex poisoned")
890            .stdout_history
891            .clone()
892            .into_iter()
893            .collect()
894    }
895
896    fn captured_stdout_raw(&self) -> Vec<u8> {
897        self.shared
898            .queues
899            .lock()
900            .expect("queue mutex poisoned")
901            .stdout_raw
902            .clone()
903    }
904
905    /// Return the retained stderr history.
906    pub fn captured_stderr(&self) -> Vec<Vec<u8>> {
907        if self.config.stderr_mode == StderrMode::Stdout {
908            return Vec::new();
909        }
910        self.shared
911            .queues
912            .lock()
913            .expect("queue mutex poisoned")
914            .stderr_history
915            .clone()
916            .into_iter()
917            .collect()
918    }
919
920    fn captured_stderr_raw(&self) -> Vec<u8> {
921        if self.config.stderr_mode == StderrMode::Stdout {
922            return Vec::new();
923        }
924        self.shared
925            .queues
926            .lock()
927            .expect("queue mutex poisoned")
928            .stderr_raw
929            .clone()
930    }
931
932    /// Return the retained combined stdout/stderr event history.
933    pub fn captured_combined(&self) -> Vec<StreamEvent> {
934        self.shared
935            .queues
936            .lock()
937            .expect("queue mutex poisoned")
938            .combined_history
939            .clone()
940            .into_iter()
941            .collect()
942    }
943
944    /// Return the retained byte count for one captured stream.
945    pub fn captured_stream_bytes(&self, stream: StreamKind) -> usize {
946        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
947            return 0;
948        }
949        let guard = self.shared.queues.lock().expect("queue mutex poisoned");
950        match stream {
951            StreamKind::Stdout => guard.stdout_history_bytes,
952            StreamKind::Stderr => guard.stderr_history_bytes,
953        }
954    }
955
956    /// Return the retained byte count for combined captured output.
957    pub fn captured_combined_bytes(&self) -> usize {
958        self.shared
959            .queues
960            .lock()
961            .expect("queue mutex poisoned")
962            .combined_history_bytes
963    }
964
965    /// Clear retained output history for one stream and return freed bytes.
966    pub fn clear_captured_stream(&self, stream: StreamKind) -> usize {
967        if stream == StreamKind::Stderr && self.config.stderr_mode == StderrMode::Stdout {
968            return 0;
969        }
970        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
971        match stream {
972            StreamKind::Stdout => {
973                let released = guard.stdout_history_bytes;
974                guard.stdout_history.clear();
975                guard.stdout_raw.clear();
976                guard.stdout_history_bytes = 0;
977                released
978            }
979            StreamKind::Stderr => {
980                let released = guard.stderr_history_bytes;
981                guard.stderr_history.clear();
982                guard.stderr_raw.clear();
983                guard.stderr_history_bytes = 0;
984                released
985            }
986        }
987    }
988
989    /// Clear retained combined output history and return freed bytes.
990    pub fn clear_captured_combined(&self) -> usize {
991        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
992        let released = guard.combined_history_bytes;
993        guard.combined_history.clear();
994        guard.combined_history_bytes = 0;
995        released
996    }
997
998    fn build_command(&self) -> Command {
999        let mut command = match &self.config.command {
1000            CommandSpec::Shell(command) => shell_command(command),
1001            CommandSpec::Argv(argv) => {
1002                let mut command = Command::new(&argv[0]);
1003                if argv.len() > 1 {
1004                    command.args(&argv[1..]);
1005                }
1006                command
1007            }
1008        };
1009        if let Some(cwd) = &self.config.cwd {
1010            command.current_dir(cwd);
1011        }
1012        if let Some(env) = &self.config.env {
1013            command.env_clear();
1014            command.envs(env.iter().map(|(k, v)| (k, v)));
1015        }
1016        #[cfg(windows)]
1017        {
1018            use std::os::windows::process::CommandExt;
1019
1020            // #584: defaults to CREATE_NO_WINDOW so a console child spawned
1021            // by the window-less daemon does not flash a console window,
1022            // while preserving the caller's console opinion, priority, and
1023            // CREATE_NEW_PROCESS_GROUP bits. See `windows_creation_flags`.
1024            let flags = windows_creation_flags(
1025                self.config.creationflags,
1026                self.config.create_process_group,
1027                self.config.nice,
1028            );
1029            if flags != 0 {
1030                command.creation_flags(flags);
1031            }
1032        }
1033        #[cfg(unix)]
1034        {
1035            let create_process_group = self.config.create_process_group;
1036            let nice = self.config.nice;
1037
1038            if create_process_group || nice.is_some() {
1039                use std::os::unix::process::CommandExt;
1040
1041                unsafe {
1042                    command.pre_exec(move || {
1043                        if create_process_group && libc::setpgid(0, 0) == -1 {
1044                            return Err(std::io::Error::last_os_error());
1045                        }
1046                        if let Some(nice) = nice {
1047                            let result = libc::setpriority(libc::PRIO_PROCESS, 0, nice);
1048                            if result == -1 {
1049                                return Err(std::io::Error::last_os_error());
1050                            }
1051                        }
1052                        Ok(())
1053                    });
1054                }
1055            }
1056        }
1057        command
1058    }
1059
1060    fn spawn_reader<R>(
1061        &self,
1062        pipe: R,
1063        source_stream: StreamKind,
1064        visible_stream: StreamKind,
1065        on_pipe_done: Box<dyn FnOnce() + Send>,
1066    ) where
1067        R: Read + Send + 'static,
1068    {
1069        let shared = Arc::clone(&self.shared);
1070        thread::spawn(move || {
1071            let mut reader = pipe;
1072            let mut chunk = vec![0_u8; 65536];
1073            let mut pending = Vec::new();
1074
1075            loop {
1076                match reader.read(&mut chunk) {
1077                    Ok(0) => break,
1078                    Ok(n) => {
1079                        append_raw(&shared, visible_stream, &chunk[..n]);
1080                        let lines = feed_chunk(&mut pending, &chunk[..n]);
1081                        emit_lines(&shared, visible_stream, lines);
1082                    }
1083                    Err(_) => break,
1084                }
1085            }
1086
1087            if !pending.is_empty() {
1088                emit_lines(&shared, visible_stream, vec![std::mem::take(&mut pending)]);
1089            }
1090
1091            // Clear the parent-side pipe-handle slot under its mutex
1092            // before dropping the reader. After this returns,
1093            // `kill_impl` can no longer try to `CancelIoEx` on us, so
1094            // it's safe for `reader`'s drop to close the HANDLE.
1095            on_pipe_done();
1096            drop(reader);
1097
1098            let mut guard = shared.queues.lock().expect("queue mutex poisoned");
1099            match source_stream {
1100                StreamKind::Stdout => guard.stdout_closed = true,
1101                StreamKind::Stderr => guard.stderr_closed = true,
1102            }
1103            shared.condvar.notify_all();
1104        });
1105    }
1106
1107    #[cfg(windows)]
1108    fn pipe_done_callback(&self, stream: StreamKind) -> Box<dyn FnOnce() + Send> {
1109        let handles = Arc::clone(&self.capture_pipe_handles);
1110        Box::new(move || {
1111            let mut guard = handles.lock().expect("capture pipe handles mutex poisoned");
1112            match stream {
1113                StreamKind::Stdout => guard.stdout = None,
1114                StreamKind::Stderr => guard.stderr = None,
1115            }
1116        })
1117    }
1118
1119    #[cfg(not(windows))]
1120    fn pipe_done_callback(&self, _stream: StreamKind) -> Box<dyn FnOnce() + Send> {
1121        Box::new(|| {})
1122    }
1123
1124    /// Cancel any pending blocking `read()` on the parent-side capture
1125    /// pipes so the reader threads' `read()` calls return
1126    /// `ERROR_OPERATION_ABORTED` immediately. Used by `kill_impl` to
1127    /// break the grandchild-orphan deadlock without waiting on
1128    /// `wait_for_capture_completion_with_deadline`'s safety-net.
1129    #[cfg(windows)]
1130    fn cancel_capture_io(&self) {
1131        crate::rp_rust_debug_scope!("running_process::NativeProcess::cancel_capture_io");
1132        use winapi::shared::ntdef::HANDLE;
1133        use winapi::um::ioapiset::CancelIoEx;
1134        let guard = self
1135            .capture_pipe_handles
1136            .lock()
1137            .expect("capture pipe handles mutex poisoned");
1138        if let Some(h) = guard.stdout {
1139            // SAFETY: the slot is `Some` only while the owning reader
1140            // thread still holds the `ChildStdout`, so the HANDLE is
1141            // valid for the duration of this call. The reader is
1142            // blocked in `lock()` on the same mutex if it's racing us
1143            // toward exit, so it cannot drop the pipe and close the
1144            // HANDLE until we return.
1145            unsafe {
1146                CancelIoEx(h as HANDLE, std::ptr::null_mut());
1147            }
1148        }
1149        if let Some(h) = guard.stderr {
1150            unsafe {
1151                CancelIoEx(h as HANDLE, std::ptr::null_mut());
1152            }
1153        }
1154    }
1155
1156    fn set_returncode(&self, code: i32) {
1157        self.shared.returncode.store(code as i64, Ordering::Release);
1158        self.shared.condvar.notify_all();
1159    }
1160
1161    fn wait_for_capture_completion_impl(&self) {
1162        crate::rp_rust_debug_scope!("running_process::NativeProcess::wait_for_capture_completion");
1163        if !self.config.capture {
1164            return;
1165        }
1166
1167        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1168        while !(guard.stdout_closed && guard.stderr_closed) {
1169            guard = self
1170                .shared
1171                .condvar
1172                .wait(guard)
1173                .expect("queue mutex poisoned");
1174        }
1175    }
1176
1177    /// Like `wait_for_capture_completion_impl` but bounded by `deadline`.
1178    /// Returns `true` if the reader threads flipped both closed flags on
1179    /// their own, `false` if the deadline elapsed first. On timeout the
1180    /// closed flags are force-set (and waiters notified) so downstream
1181    /// pollers stop seeing `Timeout` and start seeing `Eof`. A reader
1182    /// thread that eventually unblocks after the OS releases the pipe
1183    /// will assign `closed = true` again, which is a harmless no-op.
1184    fn wait_for_capture_completion_with_deadline_impl(&self, deadline: Instant) -> bool {
1185        crate::rp_rust_debug_scope!(
1186            "running_process::NativeProcess::wait_for_capture_completion_with_deadline"
1187        );
1188        if !self.config.capture {
1189            return true;
1190        }
1191
1192        let mut guard = self.shared.queues.lock().expect("queue mutex poisoned");
1193        while !(guard.stdout_closed && guard.stderr_closed) {
1194            let now = Instant::now();
1195            if now >= deadline {
1196                guard.stdout_closed = true;
1197                guard.stderr_closed = true;
1198                self.shared.condvar.notify_all();
1199                return false;
1200            }
1201            let (next_guard, result) = self
1202                .shared
1203                .condvar
1204                .wait_timeout(guard, deadline - now)
1205                .expect("queue mutex poisoned");
1206            guard = next_guard;
1207            if result.timed_out() && !(guard.stdout_closed && guard.stderr_closed) {
1208                guard.stdout_closed = true;
1209                guard.stderr_closed = true;
1210                self.shared.condvar.notify_all();
1211                return false;
1212            }
1213        }
1214        true
1215    }
1216}
1217
1218fn emit_lines(shared: &Arc<SharedState>, stream: StreamKind, lines: Vec<Vec<u8>>) {
1219    if lines.is_empty() {
1220        return;
1221    }
1222    let mut guard = shared.queues.lock().expect("queue mutex poisoned");
1223    for line in lines {
1224        let line_len = line.len();
1225        match stream {
1226            StreamKind::Stdout => {
1227                guard.stdout_history_bytes += line_len;
1228                guard.stdout_history.push_back(line.clone());
1229                guard.stdout_queue.push_back(line.clone());
1230            }
1231            StreamKind::Stderr => {
1232                guard.stderr_history_bytes += line_len;
1233                guard.stderr_history.push_back(line.clone());
1234                guard.stderr_queue.push_back(line.clone());
1235            }
1236        }
1237        let event = StreamEvent { stream, line };
1238        guard.combined_history_bytes += line_len;
1239        guard.combined_history.push_back(event.clone());
1240        guard.combined_queue.push_back(event);
1241    }
1242    shared.condvar.notify_all();
1243}
1244
1245fn append_raw(shared: &Arc<SharedState>, stream: StreamKind, chunk: &[u8]) {
1246    if chunk.is_empty() {
1247        return;
1248    }
1249    let mut guard = shared.queues.lock().expect("queue mutex poisoned");
1250    match stream {
1251        StreamKind::Stdout => guard.stdout_raw.extend_from_slice(chunk),
1252        StreamKind::Stderr => guard.stderr_raw.extend_from_slice(chunk),
1253    }
1254}
1255
1256/// Run a command to completion while concurrently draining stdout and stderr.
1257///
1258/// The helper forces capture on regardless of `config.capture`, returns raw
1259/// stdout/stderr bytes, and kills the child before returning
1260/// [`ProcessError::Timeout`] when `timeout` elapses.
1261pub fn run_command(
1262    mut config: ProcessConfig,
1263    timeout: Option<Duration>,
1264) -> Result<RunOutput, ProcessError> {
1265    config.capture = true;
1266    let process = NativeProcess::new(config);
1267    process.start()?;
1268
1269    let exit_code = match process.wait(timeout) {
1270        Ok(code) => code,
1271        Err(ProcessError::Timeout) => {
1272            match process.kill() {
1273                Ok(()) | Err(ProcessError::NotRunning) => {}
1274                Err(error) => return Err(error),
1275            }
1276            return Err(ProcessError::Timeout);
1277        }
1278        Err(error) => return Err(error),
1279    };
1280
1281    Ok(RunOutput {
1282        stdout: process.captured_stdout_raw(),
1283        stderr: process.captured_stderr_raw(),
1284        exit_code,
1285    })
1286}
1287
1288pub(crate) fn shell_command(command: &str) -> Command {
1289    #[cfg(windows)]
1290    {
1291        use std::os::windows::process::CommandExt;
1292
1293        let mut cmd = Command::new("cmd");
1294        cmd.raw_arg("/D /S /C \"");
1295        cmd.raw_arg(command);
1296        cmd.raw_arg("\"");
1297        cmd
1298    }
1299    #[cfg(not(windows))]
1300    {
1301        let mut cmd = Command::new("sh");
1302        cmd.arg("-lc").arg(command);
1303        cmd
1304    }
1305}
1306
1307#[cfg(test)]
1308mod tests;