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