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, command::spawn_error, 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
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    // Own the child and its worker threads in a guard
98    // so any early return below (a wait error, for example) kills the child
99    // and reaps the threads rather than orphaning the child and detaching the readers,
100    // which would keep the pipes open and leak the threads.
101    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    // The child has exited; drain the workers within the grace period.
109    // A worker still blocked because a surviving descendant holds the pipe open is detached rather than joined forever.
110    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
135/// Owns a spawned child and its capture/stdin worker threads so an early return
136/// or panic kills the child and reaps the threads instead of leaking them.
137///
138/// While armed,
139/// dropping the guard best-effort kills the child (closing the pipes so the readers observe EOF)
140/// and then joins each worker within the grace period.
141/// [`disarm`](Self::disarm) after a normal drain hands ownership back to the already-captured shared output.
142struct 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    /// Join every worker thread within the grace period, surfacing worker errors.
181    /// A worker that outlives the grace period is detached.
182    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        // The guard killed and reaped the child, so a fresh existence probe must fail with ESRCH.
438        // SAFETY: signal 0 performs an existence check without delivering a signal.
439        let alive = unsafe { libc::kill(i32::try_from(pid).unwrap(), 0) };
440        assert_eq!(alive, -1, "guard drop must kill the child");
441    }
442}