Skip to main content

running_process/
bounded.rs

1//! Bounded synchronous command execution helpers.
2
3use super::*;
4
5/// Optional containment policy for [`run_std_command_bounded_with_options`].
6///
7/// The default keeps the established bounded-run behavior. Setting
8/// [`Self::kill_when_owner_dies`] asks the host to terminate the launched
9/// command when the process that called the bounded runner dies:
10///
11/// - Linux installs `PR_SET_PDEATHSIG` in the child before `exec`, including
12///   a parent-race guard.
13/// - macOS installs the existing kqueue supervisor before `exec`.
14/// - Windows reuses the [`NativeProcess`] per-spawn kill-on-close job; it does
15///   not create a second job object.
16///
17/// On Linux and macOS this policy guarantees only direct-child termination.
18/// Those operating systems do not provide a parent-death primitive that can
19/// atomically terminate a process group, so ordinary descendants may outlive
20/// their direct parent. Windows Job Object containment covers the whole job.
21/// Callers that need Unix tree cleanup must use an application-level
22/// supervisor or an explicit tree-containment mechanism.
23#[non_exhaustive]
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub struct BoundedRunOptions {
26    /// Terminate the launched command if its bounded-run owner exits.
27    ///
28    /// Linux and macOS guarantee direct-child termination only; Windows
29    /// terminates the Job Object tree.
30    kill_when_owner_dies: bool,
31
32    /// Semantic launch priority passed to the platform command configuration.
33    ///
34    /// On Unix this is the child nice value. On Windows it selects the
35    /// existing creation priority-class mapping; numeric nice values are not
36    /// portable priority levels across those platforms.
37    nice: Option<i32>,
38}
39
40impl BoundedRunOptions {
41    /// Set whether the bounded runner asks the host to terminate the direct
42    /// child if its owner exits unexpectedly.
43    #[must_use]
44    pub fn kill_when_owner_dies(mut self, enabled: bool) -> Self {
45        self.kill_when_owner_dies = enabled;
46        self
47    }
48
49    /// Set the semantic launch priority for the bounded child.
50    #[must_use]
51    pub fn nice(mut self, nice: Option<i32>) -> Self {
52        self.nice = nice;
53        self
54    }
55}
56
57/// Run a command to completion while concurrently draining stdout and stderr.
58///
59/// The helper forces capture on regardless of `config.capture`, returns raw
60/// stdout/stderr bytes, and kills the child before returning
61/// [`ProcessError::Timeout`] when `timeout` elapses.
62pub fn run_command(
63    mut config: ProcessConfig,
64    timeout: Option<Duration>,
65) -> Result<RunOutput, ProcessError> {
66    config.capture = true;
67    let process = NativeProcess::new(config);
68    process.start()?;
69
70    let exit_code = match process.wait(timeout) {
71        Ok(code) => code,
72        Err(ProcessError::Timeout) => {
73            match process.kill() {
74                Ok(()) | Err(ProcessError::NotRunning) => {}
75                Err(error) => return Err(error),
76            }
77            return Err(ProcessError::Timeout);
78        }
79        Err(error) => return Err(error),
80    };
81
82    Ok(RunOutput {
83        stdout: process.captured_stdout_raw(),
84        stderr: process.captured_stderr_raw(),
85        exit_code,
86    })
87}
88
89struct BoundedRunCleanup<'a> {
90    process: &'a NativeProcess,
91    armed: bool,
92}
93
94impl BoundedRunCleanup<'_> {
95    fn disarm(&mut self) {
96        self.armed = false;
97    }
98}
99
100impl Drop for BoundedRunCleanup<'_> {
101    fn drop(&mut self) {
102        if !self.armed {
103            return;
104        }
105
106        // Error paths must not strand either the process tree or its capture
107        // readers. Cancel first so even a failing/redundant kill cannot leave
108        // threads blocked on pipes inherited by an escaped descendant.
109        self.process.cancel_capture_io();
110        let _ = self.process.poll();
111        if self.process.returncode().is_none() {
112            let _ = self.process.kill();
113        } else {
114            self.process.finish_capture_drain();
115        }
116        let _ = self
117            .process
118            .wait_for_capture_readers_with_deadline(kill_drain_deadline());
119    }
120}
121
122fn run_native_process_bounded(
123    process: NativeProcess,
124    timeout: Option<Duration>,
125    output_limit: usize,
126) -> Result<RunOutput, ProcessError> {
127    process.start()?;
128    let mut cleanup = BoundedRunCleanup {
129        process: &process,
130        armed: true,
131    };
132    let started = Instant::now();
133
134    let exit_code = loop {
135        if process.shared.capture_overflowed.load(Ordering::Acquire) {
136            return Err(ProcessError::OutputLimitExceeded {
137                limit: output_limit,
138            });
139        }
140        if let Some(code) = process.poll()? {
141            process.finish_capture_drain();
142            break code;
143        }
144        if timeout.is_some_and(|limit| started.elapsed() >= limit) {
145            return Err(ProcessError::Timeout);
146        }
147        thread::sleep(Duration::from_millis(5));
148    };
149
150    if !process.wait_for_capture_readers_with_deadline(kill_drain_deadline()) {
151        return Err(ProcessError::Io(std::io::Error::new(
152            std::io::ErrorKind::TimedOut,
153            "capture readers did not stop after process exit",
154        )));
155    }
156    if process.shared.capture_overflowed.load(Ordering::Acquire) {
157        return Err(ProcessError::OutputLimitExceeded {
158            limit: output_limit,
159        });
160    }
161
162    let output = RunOutput {
163        stdout: process.captured_stdout_raw(),
164        stderr: process.captured_stderr_raw(),
165        exit_code,
166    };
167    cleanup.disarm();
168    Ok(output)
169}
170
171/// Run a command with an aggregate stdout/stderr capture limit.
172///
173/// Once `output_limit` bytes have been retained, further output is drained
174/// without allocation, the contained process is terminated, and
175/// [`ProcessError::OutputLimitExceeded`] is returned. Timeout and overflow
176/// paths wait for the cancelable capture readers to actually exit before
177/// returning, including when a descendant escaped the process group while
178/// retaining a pipe.
179pub fn run_command_bounded(
180    mut config: ProcessConfig,
181    timeout: Option<Duration>,
182    output_limit: usize,
183) -> Result<RunOutput, ProcessError> {
184    config.capture = true;
185    config.create_process_group = true;
186    let process = NativeProcess::new_with_capture_limit(config, output_limit);
187    run_native_process_bounded(process, timeout, output_limit)
188}
189
190/// Run an existing [`std::process::Command`] with bounded capture.
191///
192/// Unlike [`run_command_bounded`], this entrypoint preserves non-UTF-8
193/// program paths, arguments, environment keys/values, and every other command
194/// setting exactly. Running-process still owns containment, console policy,
195/// timeout cleanup, and stdout/stderr capture.
196pub fn run_std_command_bounded(
197    command: Command,
198    timeout: Option<Duration>,
199    output_limit: usize,
200) -> Result<RunOutput, ProcessError> {
201    run_std_command_bounded_with_options(
202        command,
203        timeout,
204        output_limit,
205        BoundedRunOptions::default(),
206    )
207}
208
209/// Run an existing [`std::process::Command`] with bounded capture and
210/// explicit containment options.
211///
212/// Like [`run_std_command_bounded`], this preserves the supplied command
213/// losslessly, forces null stdin plus separately captured stdout/stderr, and
214/// keeps bounded-run's process group and cleanup behavior. The options only
215/// add host-native containment; they do not add a second launch path.
216pub fn run_std_command_bounded_with_options(
217    command: Command,
218    timeout: Option<Duration>,
219    output_limit: usize,
220    options: BoundedRunOptions,
221) -> Result<RunOutput, ProcessError> {
222    let config = ProcessConfig {
223        // The command override is consumed before this placeholder can be
224        // inspected. Keeping ProcessConfig internal policy in one shape avoids
225        // a second process-launch implementation.
226        command: CommandSpec::Argv(vec!["running-process-command-override".to_string()]),
227        cwd: None,
228        env: None,
229        capture: true,
230        stderr_mode: StderrMode::Pipe,
231        creationflags: None,
232        create_process_group: true,
233        stdin_mode: StdinMode::Null,
234        nice: options.nice,
235        address_space_limit_bytes: None,
236    };
237    let process = NativeProcess::new_with_command_capture_limit(
238        command,
239        config,
240        output_limit,
241        options.kill_when_owner_dies,
242    );
243    run_native_process_bounded(process, timeout, output_limit)
244}