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}