Skip to main content

airlock/
unix.rs

1//! Unix process driver for non-containerized integration tests.
2//!
3//! This module mirrors the relevant surface of the Docker [`Driver`][crate::driver::Driver] but
4//! spawns a local binary instead of a container. It exists so that integration tests can run on
5//! Unix hosts where ADP is exercised as a real host process rather than inside a container. The
6//! code path is portable across POSIX hosts (Linux + macOS); only macOS is exercised today, but
7//! the same module is used unchanged when we opt other Unix hosts into the suite.
8//!
9//! Only the small subset of the Docker driver surface needed by the panoramic Unix runner is
10//! implemented: spawn, log capture, exit watching, and cleanup.
11
12use std::{
13    collections::HashMap,
14    path::PathBuf,
15    process::Stdio,
16    sync::{Arc, OnceLock},
17    time::Duration,
18};
19
20use saluki_error::{generic_error, ErrorContext as _, GenericError};
21use tokio::{
22    io::{AsyncBufReadExt as _, AsyncRead, BufReader},
23    process::Command,
24    sync::Mutex,
25    task::JoinHandle,
26};
27use tokio_util::sync::CancellationToken;
28use tracing::{debug, warn};
29
30/// How long [`UnixProcess::cleanup`] waits after SIGTERM before sending SIGKILL.
31#[cfg(unix)]
32const SIGTERM_GRACE: Duration = Duration::from_millis(500);
33
34/// How long [`UnixProcess::cleanup`] waits after SIGKILL for the exit watcher and log pumps before aborting them.
35const TASK_JOIN_TIMEOUT: Duration = Duration::from_secs(2);
36
37/// Shared cell that receives the exit code of a spawned [`UnixProcess`].
38///
39/// The exit watcher sets the cell before cancelling the exit token; read it after that token fires.
40/// If [`UnixProcess::cleanup`] aborts the watcher first, the cell stays empty and the token stays uncancelled.
41///
42/// The inner `Option<i32>` is `None` if the process was terminated by signal rather than exiting
43/// normally with a status code.
44pub type ExitCodeCell = Arc<OnceLock<Option<i32>>>;
45
46/// Configuration for a Unix process to spawn.
47#[derive(Clone)]
48pub struct UnixProcessConfig {
49    /// Display name used for logs and reporting.
50    pub name: String,
51    /// Absolute path to the binary to execute.
52    pub binary_path: PathBuf,
53    /// Arguments passed to the binary.
54    pub args: Vec<String>,
55    /// Environment variables to set for the process.
56    pub env: HashMap<String, String>,
57}
58
59impl UnixProcessConfig {
60    /// Creates a new configuration with the given display name and binary path.
61    pub fn new(name: impl Into<String>, binary_path: impl Into<PathBuf>) -> Self {
62        Self {
63            name: name.into(),
64            binary_path: binary_path.into(),
65            args: Vec::new(),
66            env: HashMap::new(),
67        }
68    }
69
70    /// Sets the arguments for the process.
71    pub fn with_args(mut self, args: Vec<String>) -> Self {
72        self.args = args;
73        self
74    }
75
76    /// Sets all environment variables for the process at once.
77    pub fn with_env_map(mut self, env: HashMap<String, String>) -> Self {
78        self.env = env;
79        self
80    }
81}
82
83/// A trait-object-friendly sink for log lines captured from a Unix process.
84///
85/// This is intentionally minimal so consumers can implement it on their own log buffer type
86/// without depending on `airlock`.
87pub trait LogSink: Send + Sync {
88    /// Pushes a captured log line. `is_stderr` is `true` for lines that came from the
89    /// process's stderr stream, `false` for stdout.
90    fn push_line(&mut self, line: String, is_stderr: bool);
91}
92
93/// A spawned Unix process and its supporting tasks.
94///
95/// The exit watcher owns the child, records its exit code in [`ExitCodeCell`], and cancels the provided exit token.
96/// Background log pumps forward stdout/stderr lines to a shared sink.
97///
98/// The child leads a new process group so signals reach it and descendants that remain in the group.
99/// Call [`cleanup`][Self::cleanup] to give the group time to shut down gracefully before sending SIGKILL.
100/// Dropping the value also sends SIGKILL unless `cleanup` has already sent it.
101///
102/// # Limits
103///
104/// - Descendants can escape group signals by changing groups (for example with `setsid`). If they hold stdout or
105///   stderr open, `cleanup` aborts the log pumps after a timeout.
106/// - `Drop` does not run if the owning process is killed by SIGKILL or the OOM killer, or calls `std::process::exit`.
107/// - The OS may reuse an empty group's ID before `killpg` runs, risking a signal to an unrelated group.
108pub struct UnixProcess {
109    name: String,
110    /// Process group ID to kill on drop, or `None` after `cleanup` sends SIGKILL or if spawning returned no PID.
111    #[cfg(unix)]
112    process_group: Option<i32>,
113    exit_code: ExitCodeCell,
114    /// The exit watcher, then the stdout and stderr log pumps.
115    tasks: Vec<JoinHandle<()>>,
116}
117
118impl UnixProcess {
119    /// Spawns the process described by `config`. The provided `log_sink` receives each line of
120    /// captured stdout/stderr; the provided `exit_token` is cancelled when the process exits.
121    pub async fn spawn(
122        config: UnixProcessConfig, log_sink: Arc<Mutex<dyn LogSink>>, exit_token: CancellationToken,
123    ) -> Result<Self, GenericError> {
124        if !config.binary_path.exists() {
125            return Err(generic_error!(
126                "Binary not found at expected path: {}",
127                config.binary_path.display()
128            ));
129        }
130
131        let mut cmd = Command::new(&config.binary_path);
132        cmd.args(&config.args)
133            .envs(&config.env)
134            .stdout(Stdio::piped())
135            .stderr(Stdio::piped())
136            .kill_on_drop(true);
137        // Group the child and its descendants so `cleanup` and `Drop` can signal them together.
138        #[cfg(unix)]
139        cmd.process_group(0);
140
141        let mut child = cmd
142            .spawn()
143            .with_error_context(|| format!("Failed to spawn '{}'.", config.binary_path.display()))?;
144
145        #[cfg(unix)]
146        let process_group = child.id().map(|pid| pid as i32);
147
148        let stdout = child
149            .stdout
150            .take()
151            .ok_or_else(|| generic_error!("Failed to capture stdout."))?;
152        let stderr = child
153            .stderr
154            .take()
155            .ok_or_else(|| generic_error!("Failed to capture stderr."))?;
156
157        let stdout_task = spawn_log_pump(stdout, log_sink.clone(), false);
158        let stderr_task = spawn_log_pump(stderr, log_sink, true);
159
160        // Exit watcher: moves the child into the task, calls `wait()`, records the exit code,
161        // and fires the exit token so blocked assertions (process_stable_for / adp_exits_with)
162        // unblock immediately rather than waiting for the test's own cleanup phase.
163        let exit_code: ExitCodeCell = Arc::new(OnceLock::new());
164        let exit_code_for_watcher = exit_code.clone();
165        let name_for_watcher = config.name.clone();
166        let exit_task = tokio::spawn(async move {
167            match child.wait().await {
168                Ok(status) => {
169                    let code = status.code();
170                    debug!(name = %name_for_watcher, ?code, "Unix process exited.");
171                    let _ = exit_code_for_watcher.set(code);
172                }
173                Err(e) => {
174                    warn!(name = %name_for_watcher, error = %e, "Failed to wait on Unix process; treating as exited.");
175                    let _ = exit_code_for_watcher.set(None);
176                }
177            }
178            exit_token.cancel();
179        });
180
181        Ok(Self {
182            name: config.name,
183            #[cfg(unix)]
184            process_group,
185            exit_code,
186            tasks: vec![exit_task, stdout_task, stderr_task],
187        })
188    }
189
190    /// Returns the display name of the process.
191    pub fn name(&self) -> &str {
192        &self.name
193    }
194
195    /// Returns a clone of the shared [`ExitCodeCell`].
196    ///
197    /// Wait for the exit token passed to [`spawn`][Self::spawn] before reading.
198    pub fn exit_code_cell(&self) -> ExitCodeCell {
199        self.exit_code.clone()
200    }
201
202    /// Stops the process group and waits for its background tasks.
203    ///
204    /// Sends SIGTERM to the group, allows a grace period for shutdown, then sends SIGKILL. Waits for the exit watcher
205    /// and log pumps to finish, aborting unfinished tasks after a timeout.
206    ///
207    /// Dropping this future before SIGKILL drops the [`UnixProcess`], which sends SIGKILL instead.
208    pub async fn cleanup(mut self) {
209        #[cfg(unix)]
210        if let Some(pgid) = self.process_group {
211            signal_process_group(pgid, libc::SIGTERM);
212            tokio::time::sleep(SIGTERM_GRACE).await;
213            signal_process_group(pgid, libc::SIGKILL);
214            // Retain the group ID through the grace period so cancellation still triggers SIGKILL in `Drop`.
215            self.process_group = None;
216        }
217
218        let mut tasks = std::mem::take(&mut self.tasks);
219        let joined = tokio::time::timeout(TASK_JOIN_TIMEOUT, async {
220            for task in &mut tasks {
221                let _ = task.await;
222            }
223        })
224        .await;
225        if joined.is_err() {
226            warn!(
227                name = %self.name,
228                "Process tasks still running after SIGKILL; aborting them. A descendant outside the process group \
229                 may still hold the output pipes."
230            );
231            for task in &tasks {
232                task.abort();
233            }
234        }
235    }
236}
237
238impl Drop for UnixProcess {
239    fn drop(&mut self) {
240        #[cfg(unix)]
241        if let Some(pgid) = self.process_group.take() {
242            // Let the detached tasks reap the child and drain its output.
243            signal_process_group(pgid, libc::SIGKILL);
244            warn!(name = %self.name, pgid, "UnixProcess dropped before cleanup; killed its process group.");
245        }
246    }
247}
248
249/// Sends `signal` to every process in group `pgid`, ignoring failure (the group may already be empty).
250#[cfg(unix)]
251fn signal_process_group(pgid: i32, signal: libc::c_int) {
252    // SAFETY: `killpg` takes no pointers and touches no memory of this process.
253    unsafe {
254        libc::killpg(pgid, signal);
255    }
256}
257
258fn spawn_log_pump<R>(reader: R, sink: Arc<Mutex<dyn LogSink>>, is_stderr: bool) -> JoinHandle<()>
259where
260    R: AsyncRead + Unpin + Send + 'static,
261{
262    let mut lines = BufReader::new(reader).lines();
263    tokio::spawn(async move {
264        loop {
265            match lines.next_line().await {
266                Ok(Some(line)) => {
267                    let mut sink = sink.lock().await;
268                    sink.push_line(line, is_stderr);
269                }
270                Ok(None) => break,
271                Err(e) => {
272                    debug!(error = %e, "Log pump read error; stopping.");
273                    break;
274                }
275            }
276        }
277    })
278}
279
280#[cfg(all(test, unix))]
281mod tests {
282    use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
283
284    use super::*;
285
286    /// Timeout for expected test events.
287    const WAIT: Duration = Duration::from_secs(5);
288
289    /// Forwards captured lines to a channel.
290    ///
291    /// Both log pumps own the sender, so the channel closes when they finish or are aborted. The test scripts keep
292    /// their output pipes open until exit, making EOF an exit signal even for zombie processes.
293    struct ChannelSink(UnboundedSender<String>);
294
295    impl LogSink for ChannelSink {
296        fn push_line(&mut self, line: String, _is_stderr: bool) {
297            let _ = self.0.send(line);
298        }
299    }
300
301    /// Kills a process group when the test ends, including on panic.
302    struct KillGroupOnDrop(i32);
303
304    impl Drop for KillGroupOnDrop {
305        fn drop(&mut self) {
306            signal_process_group(self.0, libc::SIGKILL);
307        }
308    }
309
310    /// Spawns `/bin/sh -c script` and returns its output receiver and exit token.
311    async fn spawn_shell(script: &str) -> (UnixProcess, UnboundedReceiver<String>, CancellationToken) {
312        let (tx, rx) = unbounded_channel();
313        let sink: Arc<Mutex<dyn LogSink>> = Arc::new(Mutex::new(ChannelSink(tx)));
314        let exit_token = CancellationToken::new();
315        let config = UnixProcessConfig::new("test", "/bin/sh").with_args(vec!["-c".to_string(), script.to_string()]);
316        let process = UnixProcess::spawn(config, sink, exit_token.clone())
317            .await
318            .expect("spawning /bin/sh should succeed");
319        (process, rx, exit_token)
320    }
321
322    /// Returns the first line that starts with `prefix`.
323    async fn wait_for_line(rx: &mut UnboundedReceiver<String>, prefix: &str) -> String {
324        tokio::time::timeout(WAIT, async {
325            while let Some(line) = rx.recv().await {
326                if line.starts_with(prefix) {
327                    return line;
328                }
329            }
330            panic!("output closed before a line starting with {prefix:?} (is the script's tool installed?)");
331        })
332        .await
333        .unwrap_or_else(|_| panic!("no line starting with {prefix:?} within {WAIT:?}"))
334    }
335
336    /// Collects remaining lines until both log pumps release the sink.
337    async fn wait_for_pipes_closed(rx: &mut UnboundedReceiver<String>) -> Vec<String> {
338        tokio::time::timeout(WAIT, async {
339            let mut lines = Vec::new();
340            while let Some(line) = rx.recv().await {
341                lines.push(line);
342            }
343            lines
344        })
345        .await
346        .expect("a process still holds the output pipes")
347    }
348
349    // Regression: `Drop` only logged, leaving host processes holding test ports after a macOS test timeout.
350    // The detached watcher owns the `Child`, so dropping `UnixProcess` does not trigger `kill_on_drop`.
351    #[tokio::test]
352    async fn dropping_a_process_kills_its_whole_group() {
353        // The backgrounded `sleep` shares the shell's process group and output pipes.
354        let (process, mut rx, exit_token) = spawn_shell("sleep 60 & echo ready; wait").await;
355        let exit_code = process.exit_code_cell();
356        wait_for_line(&mut rx, "ready").await;
357
358        drop(process);
359
360        wait_for_pipes_closed(&mut rx).await;
361        tokio::time::timeout(WAIT, exit_token.cancelled())
362            .await
363            .expect("the exit watcher should fire the exit token after the kill");
364        assert_eq!(exit_code.get(), Some(&None), "the shell should die of a signal");
365    }
366
367    #[tokio::test]
368    async fn dropping_cleanup_during_its_sigterm_grace_still_kills_the_group() {
369        // `trap ''` is inherited, so the backgrounded `sleep` ignores SIGTERM too and only SIGKILL can end the group.
370        let (process, mut rx, _exit_token) = spawn_shell("trap '' TERM; sleep 60 & echo ready; wait").await;
371        wait_for_line(&mut rx, "ready").await;
372
373        let cleanup = tokio::time::timeout(SIGTERM_GRACE / 2, process.cleanup()).await;
374        assert!(cleanup.is_err(), "cleanup should still be waiting out its grace");
375
376        wait_for_pipes_closed(&mut rx).await;
377    }
378
379    #[tokio::test]
380    async fn cleanup_sends_sigterm_before_sigkill() {
381        let (process, mut rx, _exit_token) =
382            spawn_shell("trap 'echo got-sigterm; exit 0' TERM; sleep 60 & echo ready; wait").await;
383        let exit_code = process.exit_code_cell();
384        wait_for_line(&mut rx, "ready").await;
385
386        tokio::time::timeout(WAIT, process.cleanup())
387            .await
388            .expect("cleanup should return");
389
390        let lines = wait_for_pipes_closed(&mut rx).await;
391        assert!(lines.iter().any(|line| line == "got-sigterm"), "lines: {lines:?}");
392        assert_eq!(exit_code.get(), Some(&Some(0)), "the trap should have exited the shell");
393    }
394
395    #[tokio::test]
396    async fn cleanup_returns_when_an_escaped_descendant_holds_the_output_pipes() {
397        // Perl's `setpgrp` works on macOS and Linux. `$|` flushes the PID before `exec`.
398        // The escaped `sleep` holds the shell's output pipes open from a separate process group.
399        let (process, mut rx, _exit_token) =
400            spawn_shell(r#"perl -e '$| = 1; setpgrp(0, 0); print "escaped $$\n"; exec @ARGV' sleep 60 & wait"#).await;
401        let line = wait_for_line(&mut rx, "escaped ").await;
402        let escaped_pid: i32 = line["escaped ".len()..].trim().parse().expect("a PID after `escaped `");
403        let _guard = KillGroupOnDrop(escaped_pid);
404        // SAFETY: `getpgid` takes no pointers and touches no memory of this process.
405        assert_eq!(
406            unsafe { libc::getpgid(escaped_pid) },
407            escaped_pid,
408            "the descendant should lead its own group"
409        );
410
411        tokio::time::timeout(SIGTERM_GRACE + TASK_JOIN_TIMEOUT + WAIT, process.cleanup())
412            .await
413            .expect("cleanup should give up on the pumps after its bound");
414
415        // The escaped `sleep` still runs, so only aborting the pumps can drop the sink.
416        tokio::time::timeout(Duration::from_secs(1), async { while rx.recv().await.is_some() {} })
417            .await
418            .expect("cleanup should have aborted the log pumps");
419    }
420}