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