Skip to main content

sui_spec/
exec.rs

1//! Typed dual-subprocess runner — the shared substrate every ParityCheck
2//! rides on.
3//!
4//! The original `sui-sweep` ran two subprocesses inline.  Reaching for the
5//! same shape from a second site (the rebuild-probe sweep, the eventual
6//! `sui rebuild-shadow` subcommand, the operator-facing `fleet rebuild
7//! --shadow-sui` wrapper) makes this the canonical place to put it.
8//!
9//! Two construction guarantees this module pins down:
10//!
11//! 1. **NO SHELL.** Every subprocess is built with typed `Command`
12//!    pieces.  There is no `bash -c` anywhere in the parity path.
13//! 2. **Timeout is mandatory.** Every invocation goes through
14//!    [`run_with_timeout`], which SIGKILLs the child after `timeout`.
15//!    The parity harness must never hang on a runaway evaluator.
16
17use std::process::{Command, ExitStatus, Stdio};
18use std::sync::mpsc;
19use std::time::{Duration, Instant};
20
21use serde::{Deserialize, Serialize};
22
23/// Captured output of one subprocess invocation.
24///
25/// Carries enough information for the sweep report to be self-contained
26/// without re-running anything: both stdout and stderr are retained, the
27/// duration is wall-clock between spawn and reap, and `timed_out` is true
28/// iff the watchdog had to SIGKILL the child.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct CapturedOutput {
31    /// Process exit code, or `None` if the OS didn't surface one
32    /// (signal-killed, including our own SIGKILL on timeout).
33    pub exit_code: Option<i32>,
34    /// `true` iff `exit_code == Some(0)`.  Pre-computed so callers
35    /// don't have to remember the unwrap.
36    pub success: bool,
37    /// Standard output, lossy-decoded as UTF-8 (Nix only emits ASCII
38    /// or valid UTF-8 in practice; we never silently swallow bytes).
39    pub stdout: String,
40    /// Standard error, lossy-decoded as UTF-8.
41    pub stderr: String,
42    /// Wall-clock duration of the invocation.
43    pub duration: Duration,
44    /// `true` iff the watchdog had to kill the child.
45    pub timed_out: bool,
46}
47
48impl CapturedOutput {
49    /// Build a [`CapturedOutput`] from the raw parts the standard
50    /// library produces.
51    #[must_use]
52    pub fn from_parts(
53        status: ExitStatus,
54        stdout: Vec<u8>,
55        stderr: Vec<u8>,
56        duration: Duration,
57        timed_out: bool,
58    ) -> Self {
59        let exit_code = status.code();
60        let success = exit_code == Some(0);
61        Self {
62            exit_code,
63            success,
64            stdout: String::from_utf8_lossy(&stdout).into_owned(),
65            stderr: String::from_utf8_lossy(&stderr).into_owned(),
66            duration,
67            timed_out,
68        }
69    }
70
71    /// Build an output representing a spawn failure (binary missing,
72    /// `EACCES`, etc.).  The probe still gets recorded; the verdict
73    /// will be a "fail-only" against whichever side blew up.
74    #[must_use]
75    pub fn spawn_failure(message: String, duration: Duration) -> Self {
76        Self {
77            exit_code: None,
78            success: false,
79            stdout: String::new(),
80            stderr: format!("spawn failed: {message}"),
81            duration,
82            timed_out: false,
83        }
84    }
85}
86
87/// Render a [`Command`] back to a typed argv vector for the report.
88///
89/// `Command::get_args` returns `OsStr`s; we lossily convert because
90/// the report is JSON and JSON can't represent non-UTF-8 anyway.
91/// Operators reading the report on a screen never miss anything that
92/// matters.
93#[must_use]
94pub fn command_argv(cmd: &Command) -> Vec<String> {
95    let program = cmd.get_program().to_string_lossy().into_owned();
96    let mut argv = vec![program];
97    for arg in cmd.get_args() {
98        argv.push(arg.to_string_lossy().into_owned());
99    }
100    argv
101}
102
103/// Run a single command with a hard SIGKILL on timeout.
104///
105/// Watchdog is a one-shot thread parked on a `recv_timeout` against an
106/// mpsc channel; on timeout it calls `libc::kill(pid, SIGKILL)`.  On
107/// success the main thread sends a completion message which causes the
108/// watchdog to exit harmlessly.
109///
110/// # Errors
111///
112/// Returns `Err` only if `spawn()` itself fails.  Subprocess non-zero
113/// exits + timeouts are surfaced through the [`CapturedOutput`].
114pub fn run_with_timeout(
115    cmd: &mut Command,
116    timeout: Duration,
117) -> std::io::Result<CapturedOutput> {
118    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
119    let start = Instant::now();
120    let child = cmd.spawn()?;
121    let pid = child.id();
122
123    let (tx, rx) = mpsc::channel::<()>();
124    let watchdog = std::thread::spawn(move || -> bool {
125        // Returns true iff the watchdog fired (i.e. timeout reached).
126        match rx.recv_timeout(timeout) {
127            Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => false,
128            Err(mpsc::RecvTimeoutError::Timeout) => {
129                #[cfg(unix)]
130                // SAFETY: `libc::kill` with SIGKILL and a freshly-spawned
131                // child PID is sound; the worst case is a no-op if the
132                // child already exited (kernel reuses PIDs only after
133                // reap).
134                unsafe { libc::kill(pid as i32, libc::SIGKILL); }
135                true
136            }
137        }
138    });
139
140    let output = child.wait_with_output()?;
141    // Tell the watchdog we're done; ignore send errors (it may already
142    // have fired and dropped the rx).
143    let _ = tx.send(());
144    let timed_out = watchdog.join().unwrap_or(false);
145    let duration = start.elapsed();
146
147    Ok(CapturedOutput::from_parts(
148        output.status,
149        output.stdout,
150        output.stderr,
151        duration,
152        timed_out,
153    ))
154}
155
156/// Run two commands sequentially under the same timeout each, returning
157/// `(sui_output, nix_output)`.
158///
159/// Sequential (not parallel) on purpose: nix's eval cache lock, sui's
160/// store-write lock, and disk-IO contention all behave better when the
161/// engines don't race for the same FS resources.  Wall-clock loss is
162/// small relative to a 30 s timeout budget.
163///
164/// # Errors
165///
166/// Returns the first spawn error encountered; both invocations are still
167/// attempted independently — a sui spawn failure does not skip nix.
168pub fn dual_run(
169    sui: &mut Command,
170    nix: &mut Command,
171    timeout: Duration,
172) -> (CapturedOutput, CapturedOutput) {
173    let sui_out = match run_with_timeout(sui, timeout) {
174        Ok(out) => out,
175        Err(e) => CapturedOutput::spawn_failure(e.to_string(), Duration::ZERO),
176    };
177    let nix_out = match run_with_timeout(nix, timeout) {
178        Ok(out) => out,
179        Err(e) => CapturedOutput::spawn_failure(e.to_string(), Duration::ZERO),
180    };
181    (sui_out, nix_out)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn captured_output_success_marks_success_flag() {
190        let out = CapturedOutput::from_parts(
191            fake_exit(0),
192            b"hi".to_vec(),
193            b"".to_vec(),
194            Duration::from_millis(1),
195            false,
196        );
197        assert!(out.success);
198        assert_eq!(out.exit_code, Some(0));
199        assert_eq!(out.stdout, "hi");
200    }
201
202    #[test]
203    fn captured_output_nonzero_is_not_success() {
204        let out = CapturedOutput::from_parts(
205            fake_exit(1),
206            b"".to_vec(),
207            b"oops".to_vec(),
208            Duration::from_millis(1),
209            false,
210        );
211        assert!(!out.success);
212        assert_eq!(out.exit_code, Some(1));
213    }
214
215    #[test]
216    fn spawn_failure_records_message() {
217        let out = CapturedOutput::spawn_failure("no such file".into(), Duration::ZERO);
218        assert!(!out.success);
219        assert!(out.stderr.contains("no such file"));
220        assert_eq!(out.exit_code, None);
221    }
222
223    #[test]
224    fn command_argv_renders_program_and_args() {
225        let mut cmd = Command::new("/bin/echo");
226        cmd.args(["hello", "world"]);
227        let argv = command_argv(&cmd);
228        assert_eq!(argv, vec!["/bin/echo", "hello", "world"]);
229    }
230
231    #[test]
232    fn timeout_kills_runaway_child() {
233        // Skip the test on platforms without `/bin/sleep` (windows).
234        if !std::path::Path::new("/bin/sleep").exists() {
235            return;
236        }
237        let mut cmd = Command::new("/bin/sleep");
238        cmd.arg("30");
239        let out = run_with_timeout(&mut cmd, Duration::from_millis(100))
240            .expect("spawn should succeed");
241        assert!(out.timed_out, "watchdog must have fired");
242        // SIGKILL doesn't produce an exit code; the OS surfaces None.
243        assert!(out.exit_code.is_none() || out.exit_code == Some(-1));
244    }
245
246    fn fake_exit(code: i32) -> ExitStatus {
247        // Use `false` (exit 1) or `true` (exit 0) since rust's ExitStatus
248        // has no public constructor on stable.  We invoke the binary that
249        // matches the desired code; this is test-only.
250        let bin = if code == 0 { "/usr/bin/true" } else { "/usr/bin/false" };
251        let alt = if code == 0 { "/bin/true" } else { "/bin/false" };
252        let path = if std::path::Path::new(bin).exists() { bin } else { alt };
253        std::process::Command::new(path)
254            .status()
255            .expect("status must succeed")
256    }
257}