Skip to main content

optimization_job_api/
process.rs

1//! Shell-free process groups and bounded I/O, extracted/adapted from route_agent.
2use std::{
3    io::{self, Read, Write},
4    process::{Child, Command, ExitStatus, Stdio},
5    thread,
6    time::{Duration, Instant},
7};
8pub fn group(command: &mut Command) {
9    #[cfg(unix)]
10    {
11        use std::os::unix::process::CommandExt;
12        command.process_group(0);
13    }
14}
15pub fn terminate_process_group(child: &mut Child) {
16    #[cfg(unix)]
17    {
18        if let Ok(pid) = i32::try_from(child.id()) {
19            unsafe {
20                libc::kill(-pid, libc::SIGKILL);
21            }
22        }
23    }
24    let _ = child.kill();
25}
26/// Keep draining beyond the limit to avoid pipe deadlocks; never retain excess bytes.
27pub fn drain(mut reader: impl Read, maximum: usize) -> io::Result<(Vec<u8>, bool)> {
28    let mut out = Vec::new();
29    let mut overflow = false;
30    let mut buf = [0u8; 8192];
31    loop {
32        let n = reader.read(&mut buf)?;
33        if n == 0 {
34            break;
35        }
36        let keep = n.min(maximum.saturating_sub(out.len()));
37        out.extend_from_slice(&buf[..keep]);
38        overflow |= keep < n;
39    }
40    Ok((out, overflow))
41}
42pub fn bounded_command(
43    argv: &[String],
44    input: Vec<u8>,
45    timeout: Duration,
46    maximum: usize,
47) -> io::Result<(ExitStatus, Vec<u8>, Vec<u8>)> {
48    bounded_command_until(argv, input, timeout, maximum, &|| false)
49}
50/// [`bounded_command`] that also stops the command's process group, with an
51/// `Interrupted` error, as soon as `abort` returns true. The command runs in
52/// its own group, so a terminal's SIGINT to the caller does not reach it.
53pub fn bounded_command_until(
54    argv: &[String],
55    input: Vec<u8>,
56    timeout: Duration,
57    maximum: usize,
58    abort: &dyn Fn() -> bool,
59) -> io::Result<(ExitStatus, Vec<u8>, Vec<u8>)> {
60    let exe = argv.first().ok_or_else(|| io::Error::other("empty argv"))?;
61    let mut command = Command::new(exe);
62    command
63        .args(&argv[1..])
64        .stdin(Stdio::piped())
65        .stdout(Stdio::piped())
66        .stderr(Stdio::piped());
67    group(&mut command);
68    let mut child = command.spawn()?;
69    let mut stdin = child.stdin.take().unwrap();
70    let writer = thread::spawn(move || stdin.write_all(&input));
71    let stdout = child.stdout.take().unwrap();
72    let stderr = child.stderr.take().unwrap();
73    let a = thread::spawn(move || drain(stdout, maximum));
74    let b = thread::spawn(move || drain(stderr, maximum));
75    let start = Instant::now();
76    let mut aborted = false;
77    let finished = loop {
78        if exited(&mut child)? {
79            break true;
80        }
81        if abort() {
82            aborted = true;
83            break false;
84        }
85        if start.elapsed() >= timeout {
86            break false;
87        }
88        thread::sleep(Duration::from_millis(10));
89    };
90    // A parent may exit leaving descendants holding the pipes. Clean the group
91    // even on success before joining bounded pipe readers. The exited leader
92    // is not reaped yet, so its PID, which names the group, cannot have been
93    // reused by another process group.
94    terminate_process_group(&mut child);
95    let status = child.wait();
96    let write_result = writer
97        .join()
98        .map_err(|_| io::Error::other("stdin thread panicked"))?;
99    let (out, oa) = a
100        .join()
101        .map_err(|_| io::Error::other("stdout thread panicked"))??;
102    let (err, ob) = b
103        .join()
104        .map_err(|_| io::Error::other("stderr thread panicked"))??;
105    if aborted {
106        return Err(io::Error::new(
107            io::ErrorKind::Interrupted,
108            "command interrupted",
109        ));
110    }
111    let status = finished
112        .then_some(status?)
113        .ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "command deadline exceeded"))?;
114    write_result?;
115    if oa || ob {
116        return Err(io::Error::other("command stream exceeds configured cap"));
117    }
118    Ok((status, out, err))
119}
120/// True once the child has exited, without reaping it: while the child is a
121/// zombie, its PID cannot be reused.
122#[cfg(target_os = "linux")]
123fn exited(child: &mut Child) -> io::Result<bool> {
124    let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
125    let rc = unsafe {
126        libc::waitid(
127            libc::P_PID,
128            child.id() as libc::id_t,
129            &mut info,
130            libc::WEXITED | libc::WNOWAIT | libc::WNOHANG,
131        )
132    };
133    if rc < 0 {
134        return Err(io::Error::last_os_error());
135    }
136    Ok(unsafe { info.si_pid() } != 0)
137}
138#[cfg(not(target_os = "linux"))]
139fn exited(child: &mut Child) -> io::Result<bool> {
140    Ok(child.try_wait()?.is_some())
141}
142/// Bounded on-disk diagnostics, with pipes drained after cap is reached.
143pub fn drain_file(
144    mut reader: impl Read,
145    mut file: std::fs::File,
146    maximum: usize,
147) -> io::Result<()> {
148    let mut kept = 0;
149    let mut buf = [0u8; 8192];
150    loop {
151        let n = reader.read(&mut buf)?;
152        if n == 0 {
153            break;
154        }
155        let k = n.min(maximum.saturating_sub(kept));
156        file.write_all(&buf[..k])?;
157        kept += k;
158    }
159    file.sync_data()
160}
161/// Check Linux process-group membership, counting zombies as already stopped.
162/// Returns an error when liveness cannot be established. Escaped sessions are
163/// outside the foreground backend's contract; use systemd for containment.
164#[cfg(target_os = "linux")]
165pub fn group_stopped(pgid: u32) -> io::Result<bool> {
166    // kill(0) uses the caller's PID namespace and avoids unrelated /proc access.
167    let rc = unsafe { libc::kill(-(pgid as i32), 0) };
168    if rc < 0 {
169        let e = io::Error::last_os_error();
170        return if e.raw_os_error() == Some(libc::ESRCH) {
171            Ok(true)
172        } else {
173            Err(e)
174        };
175    }
176    let namespace = std::fs::read_link("/proc/self/ns/pid")?;
177    let own_status = std::fs::read_to_string("/proc/self/status")?;
178    let depth = own_status
179        .lines()
180        .find_map(|l| l.strip_prefix("NSpgid:"))
181        .ok_or_else(|| io::Error::other("NSpgid unavailable"))?
182        .split_whitespace()
183        .count();
184    for entry in std::fs::read_dir("/proc")? {
185        let entry = entry?;
186        if entry.file_name().to_string_lossy().parse::<u32>().is_err() {
187            continue;
188        }
189        // A process that exits during the scan is gone, not unknown.
190        let vanished = |e: &io::Error| {
191            e.kind() == io::ErrorKind::NotFound || e.raw_os_error() == Some(libc::ESRCH)
192        };
193        let status = match std::fs::read_to_string(entry.path().join("status")) {
194            Ok(s) => s,
195            Err(e) if vanished(&e) => continue,
196            Err(e) => return Err(e),
197        };
198        let groups: Vec<_> = status
199            .lines()
200            .find_map(|l| l.strip_prefix("NSpgid:"))
201            .unwrap_or("")
202            .split_whitespace()
203            .collect();
204        if groups.len() != depth || groups.last().and_then(|s| s.parse::<u32>().ok()) != Some(pgid)
205        {
206            continue;
207        }
208        let other = match std::fs::read_link(entry.path().join("ns/pid")) {
209            Ok(n) => n,
210            Err(e) if vanished(&e) => continue,
211            Err(e) => return Err(e),
212        };
213        if other != namespace {
214            continue;
215        }
216        let state = status
217            .lines()
218            .find_map(|l| l.strip_prefix("State:"))
219            .and_then(|s| s.split_whitespace().next());
220        if state.is_none() {
221            return Err(io::Error::other("process state unavailable"));
222        }
223        if !matches!(state, Some("Z" | "X")) {
224            return Ok(false);
225        }
226    }
227    Ok(true)
228}
229#[cfg(not(target_os = "linux"))]
230pub fn group_stopped(_: u32) -> io::Result<bool> {
231    Err(io::Error::other("Linux process verification required"))
232}