Skip to main content

rskit_process/
sync.rs

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