1use std::io::{ErrorKind, Read, Write};
4use std::process::{ChildStdin, Command as StdCommand, Stdio};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use crate::{
9 AppError, AppResult, EnvPolicy, ErrorCode, InputPolicy, OutputPolicy, ProcessConfig, ProcessIo,
10 ProcessResult, ProcessSpec,
11};
12
13pub fn run(spec: &ProcessSpec, config: &ProcessConfig) -> AppResult<ProcessResult> {
15 if spec.program.as_os_str().is_empty() {
16 return Err(AppError::invalid_input("program", "must not be empty"));
17 }
18
19 match &config.io {
20 ProcessIo::Captured(io) => run_blocking(
21 spec,
22 config,
23 &io.input,
24 Some(&io.output),
25 pipe_stdin_stdio(&io.input)?,
26 ),
27 ProcessIo::Inherited(io) => run_blocking(
28 spec,
29 &inherited_config(config),
30 &io.input,
31 None,
32 stdin_stdio(&io.input),
33 ),
34 ProcessIo::Observed(_) => Err(AppError::invalid_input(
35 "process.io",
36 "observed mode requires async run_with_cancel",
37 )),
38 }
39}
40
41fn run_blocking(
42 spec: &ProcessSpec,
43 config: &ProcessConfig,
44 input: &InputPolicy,
45 output: Option<&OutputPolicy>,
46 stdin: Stdio,
47) -> AppResult<ProcessResult> {
48 let start = Instant::now();
49 let mut cmd = StdCommand::new(&spec.program);
50 cmd.args(&spec.args)
51 .stdin(stdin)
52 .stdout(stdout_stdio(output))
53 .stderr(stderr_stdio(output));
54
55 if let Some(dir) = &spec.dir {
56 cmd.current_dir(dir);
57 }
58
59 if matches!(spec.env_policy, EnvPolicy::Empty) {
60 cmd.env_clear();
61 }
62 for (key, value) in &spec.env {
63 cmd.env(key, value);
64 }
65
66 if config.signal.create_process_group {
67 crate::process_group::isolate(&mut cmd);
68 }
69
70 let mut child = cmd.spawn().map_err(|error| {
71 AppError::new(
72 ErrorCode::Internal,
73 format!("failed to spawn process: {error}"),
74 )
75 })?;
76 let pid = Some(child.id());
77
78 let max_output_bytes = output.and_then(|output| output.max_output_bytes);
79 let stdout = child
80 .stdout
81 .take()
82 .map(|stream| spawn_reader(stream, max_output_bytes));
83 let stderr = child
84 .stderr
85 .take()
86 .map(|stream| spawn_reader(stream, max_output_bytes));
87 let stdin = spawn_stdin_writer(child.stdin.take(), input);
88
89 let (exit_code, timed_out, synthetic_stderr) =
90 wait_with_timeout(&mut child, pid, config.timeout, config)?;
91 join_stdin(stdin)?;
92 let stdout_output = join_reader(stdout)?;
93 let mut stderr_output = join_reader(stderr)?;
94 if let Some(extra_stderr) = synthetic_stderr {
95 stderr_output.truncated |= append_bounded(
96 &mut stderr_output.bytes,
97 extra_stderr.as_bytes(),
98 max_output_bytes,
99 );
100 }
101
102 fn spawn_stdin_writer(
103 stdin: Option<ChildStdin>,
104 input: &InputPolicy,
105 ) -> Option<thread::JoinHandle<AppResult<()>>> {
106 let InputPolicy::Bytes(bytes) = input else {
107 return None;
108 };
109 let mut stdin = stdin?;
110 let bytes = bytes.clone();
111 Some(thread::spawn(move || match stdin.write_all(&bytes) {
112 Ok(()) => Ok(()),
113 Err(error) if error.kind() == ErrorKind::BrokenPipe => Ok(()),
114 Err(error) => Err(AppError::new(
115 ErrorCode::Internal,
116 format!("failed to write to stdin: {error}"),
117 )),
118 }))
119 }
120
121 Ok(ProcessResult::completed(
122 exit_code,
123 stdout_output.bytes,
124 stderr_output.bytes,
125 stdout_output.truncated,
126 stderr_output.truncated,
127 start.elapsed(),
128 timed_out,
129 false,
130 ))
131}
132
133fn stdin_stdio(input: &InputPolicy) -> Stdio {
134 match input {
135 InputPolicy::Closed => Stdio::null(),
136 InputPolicy::Bytes(_) => Stdio::piped(),
137 InputPolicy::Inherit => Stdio::inherit(),
138 }
139}
140
141fn pipe_stdin_stdio(input: &InputPolicy) -> AppResult<Stdio> {
142 match input {
143 InputPolicy::Closed => Ok(Stdio::null()),
144 InputPolicy::Bytes(_) => Ok(Stdio::piped()),
145 InputPolicy::Inherit => Err(AppError::invalid_input(
146 "process.io.input",
147 "inherited stdin requires inherited I/O mode; pipe-backed interactive stdin is not supported",
148 )),
149 }
150}
151
152fn inherited_config(config: &ProcessConfig) -> ProcessConfig {
153 let mut config = config.clone();
154 config.signal = config
155 .signal
156 .with_create_process_group(false)
157 .with_terminate_descendants(false);
158 config
159}
160
161fn stdout_stdio(output: Option<&OutputPolicy>) -> Stdio {
162 match output {
163 Some(output) if output.capture_stdout => Stdio::piped(),
164 Some(_) => Stdio::null(),
165 None => Stdio::inherit(),
166 }
167}
168
169fn stderr_stdio(output: Option<&OutputPolicy>) -> Stdio {
170 match output {
171 Some(output) if output.capture_stderr => Stdio::piped(),
172 Some(_) => Stdio::null(),
173 None => Stdio::inherit(),
174 }
175}
176
177fn wait_with_timeout(
178 child: &mut std::process::Child,
179 pid: Option<u32>,
180 timeout: Option<Duration>,
181 config: &ProcessConfig,
182) -> AppResult<(Option<i32>, bool, Option<String>)> {
183 let Some(timeout) = timeout else {
184 let status = child.wait().map_err(|error| {
185 AppError::new(
186 ErrorCode::Internal,
187 format!("process execution error: {error}"),
188 )
189 })?;
190 return Ok((status.code(), false, None));
191 };
192
193 let deadline = Instant::now() + timeout;
194 loop {
195 if let Some(status) = child.try_wait().map_err(|error| {
196 AppError::new(
197 ErrorCode::Internal,
198 format!("process execution error: {error}"),
199 )
200 })? {
201 return Ok((status.code(), false, None));
202 }
203 if Instant::now() >= deadline {
204 return terminate_and_wait(child, pid, config);
205 }
206 thread::sleep(Duration::from_millis(10));
207 }
208}
209
210fn terminate_and_wait(
211 child: &mut std::process::Child,
212 pid: Option<u32>,
213 config: &ProcessConfig,
214) -> AppResult<(Option<i32>, bool, Option<String>)> {
215 if !terminate_process(pid, config, libc::SIGTERM) {
216 child.kill().map_err(|error| {
217 AppError::new(
218 ErrorCode::Internal,
219 format!("failed to terminate process after timeout: {error}"),
220 )
221 })?;
222 }
223
224 let deadline = Instant::now() + config.signal.grace_period;
225 loop {
226 if let Some(status) = child.try_wait().map_err(|error| {
227 AppError::new(
228 ErrorCode::Internal,
229 format!("process execution error after timeout: {error}"),
230 )
231 })? {
232 return Ok((status.code(), true, None));
233 }
234 if Instant::now() >= deadline {
235 if !terminate_process(pid, config, libc::SIGKILL) {
236 child.kill().map_err(|error| {
237 AppError::new(
238 ErrorCode::Internal,
239 format!("failed to kill process after timeout: {error}"),
240 )
241 })?;
242 }
243 let status = child.wait().map_err(|error| {
244 AppError::new(
245 ErrorCode::Internal,
246 format!("process execution error after kill: {error}"),
247 )
248 })?;
249 return Ok((
250 status.code(),
251 true,
252 Some("process killed by SIGKILL after timeout".to_string()),
253 ));
254 }
255 thread::sleep(Duration::from_millis(10));
256 }
257}
258
259struct ReaderOutput {
260 bytes: Vec<u8>,
261 truncated: bool,
262}
263
264fn spawn_reader<R>(
265 mut reader: R,
266 max_bytes: Option<usize>,
267) -> thread::JoinHandle<std::io::Result<ReaderOutput>>
268where
269 R: Read + Send + 'static,
270{
271 thread::spawn(move || {
272 let mut bytes = Vec::new();
273 let mut buffer = [0_u8; 4096];
274 let mut remaining = max_bytes.unwrap_or(usize::MAX);
275 let mut truncated = false;
276 loop {
277 let read = reader.read(&mut buffer)?;
278 if read == 0 {
279 break;
280 }
281 if remaining > 0 {
282 let to_copy = remaining.min(read);
283 bytes.extend_from_slice(&buffer[..to_copy]);
284 remaining -= to_copy;
285 if to_copy < read {
286 truncated = true;
287 }
288 } else {
289 truncated = true;
290 }
291 }
292 Ok(ReaderOutput { bytes, truncated })
293 })
294}
295
296fn join_reader(
297 handle: Option<thread::JoinHandle<std::io::Result<ReaderOutput>>>,
298) -> AppResult<ReaderOutput> {
299 match handle {
300 Some(handle) => handle
301 .join()
302 .map_err(|_| {
303 AppError::new(ErrorCode::Internal, "process output reader thread panicked")
304 })?
305 .map_err(AppError::internal),
306 None => Ok(ReaderOutput {
307 bytes: Vec::new(),
308 truncated: false,
309 }),
310 }
311}
312
313fn join_stdin(handle: Option<thread::JoinHandle<AppResult<()>>>) -> AppResult<()> {
314 match handle {
315 Some(handle) => handle
316 .join()
317 .map_err(|_| AppError::new(ErrorCode::Internal, "process stdin writer panicked"))?,
318 None => Ok(()),
319 }
320}
321
322fn append_bounded(target: &mut Vec<u8>, extra: &[u8], max_bytes: Option<usize>) -> bool {
323 let Some(limit) = max_bytes else {
324 if !target.is_empty() {
325 target.push(b'\n');
326 }
327 target.extend_from_slice(extra);
328 return false;
329 };
330 if target.len() >= limit {
331 return true;
332 }
333 if !target.is_empty() && target.len() + 1 < limit {
334 target.push(b'\n');
335 }
336 let remaining = limit.saturating_sub(target.len());
337 if extra.len() > remaining {
338 target.extend_from_slice(&extra[..remaining]);
339 true
340 } else {
341 target.extend_from_slice(extra);
342 false
343 }
344}
345
346fn terminate_process(pid: Option<u32>, config: &ProcessConfig, signal: i32) -> bool {
347 if let Some(pid) = pid {
348 #[cfg(unix)]
349 unsafe {
353 let target =
354 if config.signal.create_process_group && config.signal.terminate_descendants {
355 -(pid as i32)
356 } else {
357 pid as i32
358 };
359 let result = libc::kill(target, signal);
360 if result != 0 {
361 let error = std::io::Error::last_os_error();
362 if error.raw_os_error() != Some(libc::ESRCH) {
363 return false;
364 }
365 }
366 return true;
367 }
368 }
369 false
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::{CapturedIo, ProcessIo};
376
377 #[test]
378 fn stdio_helpers_map_input_and_output_policies() {
379 assert!(pipe_stdin_stdio(&InputPolicy::Closed).is_ok());
380 assert!(pipe_stdin_stdio(&InputPolicy::Bytes(b"x".to_vec())).is_ok());
381 assert_eq!(
382 pipe_stdin_stdio(&InputPolicy::Inherit).unwrap_err().code(),
383 ErrorCode::InvalidInput
384 );
385
386 let captured = OutputPolicy::captured();
387 let _ = stdout_stdio(Some(&captured));
388 let _ = stderr_stdio(Some(&captured));
389 let discarded = OutputPolicy::observe_only();
390 let _ = stdout_stdio(Some(&discarded));
391 let _ = stderr_stdio(Some(&discarded));
392 let _ = stdout_stdio(None);
393 let _ = stderr_stdio(None);
394 let _ = stdin_stdio(&InputPolicy::Closed);
395 let _ = stdin_stdio(&InputPolicy::Bytes(Vec::new()));
396 let _ = stdin_stdio(&InputPolicy::Inherit);
397 }
398
399 #[test]
400 fn inherited_config_disables_descendant_signalling_and_join_none_is_ok() {
401 let config = ProcessConfig::default()
402 .with_io(ProcessIo::captured(CapturedIo::new()))
403 .with_timeout(None);
404 let inherited = inherited_config(&config);
405
406 assert!(!inherited.signal.create_process_group);
407 assert!(!inherited.signal.terminate_descendants);
408 assert_eq!(inherited.timeout, None);
409 join_stdin(None).unwrap();
410 let output = join_reader(None).unwrap();
411 assert!(output.bytes.is_empty());
412 assert!(!output.truncated);
413 assert!(!terminate_process(None, &config, libc::SIGTERM));
414 }
415
416 #[test]
417 fn append_bounded_preserves_separator_and_reports_truncation() {
418 let mut bytes = b"abc".to_vec();
419 assert!(!append_bounded(&mut bytes, b"def", None));
420 assert_eq!(bytes, b"abc\ndef");
421
422 let mut limited = b"abc".to_vec();
423 assert!(!append_bounded(&mut limited, b"de", Some(6)));
424 assert_eq!(limited, b"abc\nde");
425 assert!(append_bounded(&mut limited, b"f", Some(6)));
426
427 let mut already_full = b"abcdef".to_vec();
428 assert!(append_bounded(&mut already_full, b"g", Some(6)));
429 assert_eq!(already_full, b"abcdef");
430 }
431
432 #[test]
433 fn join_helpers_report_panicked_threads() {
434 let reader = std::thread::spawn(|| -> std::io::Result<ReaderOutput> {
435 panic!("reader panic");
436 });
437 let stdin = std::thread::spawn(|| -> AppResult<()> {
438 panic!("stdin panic");
439 });
440
441 let reader_error = match join_reader(Some(reader)) {
442 Ok(_) => panic!("reader panic should map to error"),
443 Err(error) => error,
444 };
445 let stdin_error = match join_stdin(Some(stdin)) {
446 Ok(_) => panic!("stdin panic should map to error"),
447 Err(error) => error,
448 };
449
450 assert_eq!(reader_error.code(), ErrorCode::Internal);
451 assert_eq!(stdin_error.code(), ErrorCode::Internal);
452 }
453
454 #[cfg(unix)]
455 #[test]
456 fn terminate_process_treats_esrch_as_already_exited() {
457 let mut child = std::process::Command::new("/usr/bin/true")
462 .stdout(std::process::Stdio::null())
463 .stderr(std::process::Stdio::null())
464 .spawn()
465 .expect("spawning /usr/bin/true succeeds");
466 let pid = child.id();
467 child.wait().expect("child exits and is reaped");
468
469 let config = ProcessConfig::default();
470 assert!(terminate_process(Some(pid), &config, 0));
471 }
472}