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}