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