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}