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