camel_component_exec/process.rs
1//! process.rs — OS layer: spawn, capped IO drain, process-tree kill.
2
3use bytes::Bytes;
4use std::process::Stdio;
5use tokio::io::AsyncReadExt;
6use tokio::process::Command;
7
8/// Result of a successful (possibly timed-out) execution.
9pub struct RawResult {
10 pub stdout: Bytes,
11 pub stderr: Bytes,
12 pub stdout_truncated: bool,
13 pub stderr_truncated: bool,
14 pub exit_code: Option<i32>,
15 pub timed_out: bool,
16}
17
18/// Drain a reader into Bytes, stopping storage (but continuing to drain) once
19/// `cap` is reached. Sets truncated=true if the stream exceeded the cap.
20pub async fn drain_with_cap<R: tokio::io::AsyncRead + Unpin>(r: R, cap: usize) -> (Bytes, bool) {
21 let mut buf = Vec::with_capacity(cap.min(8192));
22 let mut reader = r;
23 let mut truncated = false;
24 let mut tmp = [0u8; 8192];
25 loop {
26 match reader.read(&mut tmp).await {
27 Ok(0) => break,
28 Ok(n) => {
29 if !truncated && buf.len() + n <= cap {
30 buf.extend_from_slice(&tmp[..n]);
31 } else {
32 // stop storing, keep draining so the child never blocks on a full pipe
33 truncated = true;
34 }
35 }
36 Err(_) => break,
37 }
38 }
39 (Bytes::from(buf), truncated)
40}
41
42/// Spawn the child in its own process group (Unix) with given env/cwd/args.
43/// `kill_on_drop(true)` is a belt-and-suspenders guard so an accidentally-dropped
44/// Child never leaks a process (C-1 defense in depth).
45pub fn spawn(
46 exe: &std::path::Path,
47 args: &[String],
48 env: &std::collections::HashMap<String, String>,
49 cwd: &std::path::Path,
50) -> std::io::Result<tokio::process::Child> {
51 let mut cmd = Command::new(exe);
52 cmd.args(args)
53 .current_dir(cwd)
54 .env_clear()
55 .envs(env.iter())
56 .kill_on_drop(true)
57 .stdin(Stdio::piped())
58 .stdout(Stdio::piped())
59 .stderr(Stdio::piped());
60 #[cfg(unix)]
61 {
62 // process_group(0) creates a new process group for the child.
63 // This is a standard POSIX operation. The resulting Child id is
64 // always non-zero after a successful spawn, so kill_tree can safely
65 // use -pgid later.
66 cmd.process_group(0);
67 }
68 cmd.spawn()
69}
70
71/// Kill the whole process group (Unix). Best-effort on Windows (post-v1: job object).
72/// M-5: guard against pid 0/None (would signal the caller's own group if the child
73/// already exited and `id()` returned None).
74pub fn kill_tree(child: &tokio::process::Child) {
75 let Some(pid) = child.id() else { return };
76 #[cfg(unix)]
77 {
78 // SAFETY: sending SIGKILL to a negative pid signals the whole process group.
79 // This is a kernel syscall with no in-process memory-safety implications.
80 // pid is guaranteed non-zero (guarded above) so we cannot accidentally kill
81 // our own process group. The return value is intentionally ignored (best-effort
82 // kill — the child may already have exited).
83 unsafe {
84 libc::kill(-(pid as i32), libc::SIGKILL);
85 }
86 }
87 #[cfg(not(unix))]
88 {
89 let _ = child.start_kill();
90 }
91}