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