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