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    /// Select portable scheduling intent for the bounded child.
57    #[must_use]
58    pub fn priority(self, priority: crate::ProcessPriority) -> Self {
59        self.nice(priority.nice_value())
60    }
61}
62
63/// Run a command to completion while concurrently draining stdout and stderr.
64///
65/// The helper forces capture on regardless of `config.capture`, returns raw
66/// stdout/stderr bytes, and kills the child before returning
67/// [`ProcessError::Timeout`] when `timeout` elapses.
68pub fn run_command(
69    mut config: ProcessConfig,
70    timeout: Option<Duration>,
71) -> Result<RunOutput, ProcessError> {
72    config.capture = true;
73    let process = NativeProcess::new(config);
74    process.start()?;
75
76    let exit_code = match process.wait(timeout) {
77        Ok(code) => code,
78        Err(ProcessError::Timeout) => {
79            match process.kill() {
80                Ok(()) | Err(ProcessError::NotRunning) => {}
81                Err(error) => return Err(error),
82            }
83            return Err(ProcessError::Timeout);
84        }
85        Err(error) => return Err(error),
86    };
87
88    Ok(RunOutput {
89        stdout: process.captured_stdout_raw(),
90        stderr: process.captured_stderr_raw(),
91        exit_code,
92    })
93}
94
95struct BoundedRunCleanup<'a> {
96    process: &'a NativeProcess,
97    armed: bool,
98}
99
100impl BoundedRunCleanup<'_> {
101    fn disarm(&mut self) {
102        self.armed = false;
103    }
104}
105
106impl Drop for BoundedRunCleanup<'_> {
107    fn drop(&mut self) {
108        if !self.armed {
109            return;
110        }
111
112        // Error paths must not strand either the process tree or its capture
113        // readers. Cancel first so even a failing/redundant kill cannot leave
114        // threads blocked on pipes inherited by an escaped descendant.
115        self.process.cancel_capture_io();
116        let _ = self.process.poll();
117        if self.process.returncode().is_none() {
118            let _ = self.process.kill();
119        } else {
120            self.process.finish_capture_drain();
121        }
122        let _ = self
123            .process
124            .wait_for_capture_readers_with_deadline(kill_drain_deadline());
125    }
126}
127
128fn run_native_process_bounded(
129    process: NativeProcess,
130    timeout: Option<Duration>,
131    output_limit: usize,
132) -> Result<RunOutput, ProcessError> {
133    process.start()?;
134    let mut cleanup = BoundedRunCleanup {
135        process: &process,
136        armed: true,
137    };
138    let started = Instant::now();
139
140    let exit_code = loop {
141        if process.shared.capture_overflowed.load(Ordering::Acquire) {
142            return Err(ProcessError::OutputLimitExceeded {
143                limit: output_limit,
144            });
145        }
146        if let Some(code) = process.poll()? {
147            process.finish_capture_drain();
148            break code;
149        }
150        if timeout.is_some_and(|limit| started.elapsed() >= limit) {
151            return Err(ProcessError::Timeout);
152        }
153        thread::sleep(Duration::from_millis(5));
154    };
155
156    if !process.wait_for_capture_readers_with_deadline(kill_drain_deadline()) {
157        return Err(ProcessError::Io(std::io::Error::new(
158            std::io::ErrorKind::TimedOut,
159            "capture readers did not stop after process exit",
160        )));
161    }
162    if process.shared.capture_overflowed.load(Ordering::Acquire) {
163        return Err(ProcessError::OutputLimitExceeded {
164            limit: output_limit,
165        });
166    }
167
168    let output = RunOutput {
169        stdout: process.captured_stdout_raw(),
170        stderr: process.captured_stderr_raw(),
171        exit_code,
172    };
173    cleanup.disarm();
174    Ok(output)
175}
176
177/// Run a command with an aggregate stdout/stderr capture limit.
178///
179/// Once `output_limit` bytes have been retained, further output is drained
180/// without allocation, the contained process is terminated, and
181/// [`ProcessError::OutputLimitExceeded`] is returned. Timeout and overflow
182/// paths wait for the cancelable capture readers to actually exit before
183/// returning, including when a descendant escaped the process group while
184/// retaining a pipe.
185pub fn run_command_bounded(
186    mut config: ProcessConfig,
187    timeout: Option<Duration>,
188    output_limit: usize,
189) -> Result<RunOutput, ProcessError> {
190    config.capture = true;
191    config.create_process_group = true;
192    let process = NativeProcess::new_with_capture_limit(config, output_limit);
193    run_native_process_bounded(process, timeout, output_limit)
194}
195
196/// Run an existing [`std::process::Command`] with bounded capture.
197///
198/// Unlike [`run_command_bounded`], this entrypoint preserves non-UTF-8
199/// program paths, arguments, environment keys/values, and every other command
200/// setting exactly. Running-process still owns containment, console policy,
201/// timeout cleanup, and stdout/stderr capture.
202pub fn run_std_command_bounded(
203    command: Command,
204    timeout: Option<Duration>,
205    output_limit: usize,
206) -> Result<RunOutput, ProcessError> {
207    run_std_command_bounded_with_options(
208        command,
209        timeout,
210        output_limit,
211        BoundedRunOptions::default(),
212    )
213}
214
215/// Run an existing [`std::process::Command`] with bounded capture and
216/// explicit containment options.
217///
218/// Like [`run_std_command_bounded`], this preserves the supplied command
219/// losslessly, forces null stdin plus separately captured stdout/stderr, and
220/// keeps bounded-run's process group and cleanup behavior. The options only
221/// add host-native containment; they do not add a second launch path.
222pub fn run_std_command_bounded_with_options(
223    command: Command,
224    timeout: Option<Duration>,
225    output_limit: usize,
226    options: BoundedRunOptions,
227) -> Result<RunOutput, ProcessError> {
228    let config = ProcessConfig {
229        // The command override is consumed before this placeholder can be
230        // inspected. Keeping ProcessConfig internal policy in one shape avoids
231        // a second process-launch implementation.
232        command: CommandSpec::Argv(vec!["running-process-command-override".to_string()]),
233        cwd: None,
234        env: None,
235        capture: true,
236        stderr_mode: StderrMode::Pipe,
237        creationflags: None,
238        create_process_group: true,
239        stdin_mode: StdinMode::Null,
240        nice: options.nice,
241        address_space_limit_bytes: None,
242    };
243    let process = NativeProcess::new_with_command_capture_limit(
244        command,
245        config,
246        output_limit,
247        options.kill_when_owner_dies,
248    );
249    run_native_process_bounded(process, timeout, output_limit)
250}