Skip to main content

aster_forge_test/
process.rs

1//! Real child-process fixtures for integration and end-to-end tests.
2
3use std::fs::File;
4use std::io::Read;
5use std::net::TcpListener;
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command, Stdio};
8use std::time::{Duration, Instant};
9
10use crate::temp::TestTempDir;
11
12/// Reserves an ephemeral loopback port and releases it for a child process to bind.
13pub fn available_loopback_port() -> u16 {
14    TcpListener::bind(("127.0.0.1", 0))
15        .expect("failed to reserve local test port")
16        .local_addr()
17        .expect("failed to resolve local test port")
18        .port()
19}
20
21/// Child process with isolated working directory, captured logs, and kill-on-drop cleanup.
22pub struct TestProcess {
23    name: String,
24    child: Option<Child>,
25    runtime_dir: TestTempDir,
26    stdout_log: PathBuf,
27    stderr_log: PathBuf,
28}
29
30impl TestProcess {
31    /// Spawns `command` in a fresh temporary directory and captures stdout/stderr to files.
32    ///
33    /// Callers own product-specific arguments and environment variables. This helper owns only
34    /// process lifecycle and diagnostics.
35    pub fn spawn(name: &str, command: &mut Command) -> Self {
36        assert_valid_process_name(name);
37        let runtime_dir = TestTempDir::new(&format!("process-{name}"));
38        let stdout_log = runtime_dir.join("stdout.log");
39        let stderr_log = runtime_dir.join("stderr.log");
40        let stdout = File::create(&stdout_log)
41            .unwrap_or_else(|error| panic!("failed to create {}: {error}", stdout_log.display()));
42        let stderr = File::create(&stderr_log)
43            .unwrap_or_else(|error| panic!("failed to create {}: {error}", stderr_log.display()));
44
45        let child = command
46            .current_dir(runtime_dir.path())
47            .stdin(Stdio::null())
48            .stdout(Stdio::from(stdout))
49            .stderr(Stdio::from(stderr))
50            .spawn()
51            .unwrap_or_else(|error| panic!("failed to spawn test process {name}: {error}"));
52
53        Self {
54            name: name.to_string(),
55            child: Some(child),
56            runtime_dir,
57            stdout_log,
58            stderr_log,
59        }
60    }
61
62    /// Returns the fixture name used in diagnostics.
63    pub fn name(&self) -> &str {
64        &self.name
65    }
66
67    /// Returns the isolated working directory.
68    pub fn runtime_dir(&self) -> &Path {
69        self.runtime_dir.path()
70    }
71
72    /// Kills the child process and waits for it to exit. Repeated calls are harmless.
73    pub fn terminate(&mut self) {
74        let Some(mut child) = self.child.take() else {
75            return;
76        };
77        let _ = child.kill();
78        let _ = child.wait();
79    }
80
81    /// Sends SIGTERM and waits for the child to exit within `timeout`.
82    #[cfg(unix)]
83    pub fn terminate_gracefully(&mut self, timeout: Duration) -> bool {
84        let Some(child) = self.child.as_mut() else {
85            return true;
86        };
87        let status = Command::new("/bin/kill")
88            .args(["-TERM", &child.id().to_string()])
89            .status()
90            .unwrap_or_else(|error| panic!("failed to send SIGTERM to {}: {error}", self.name));
91        assert!(
92            status.success(),
93            "failed to send SIGTERM to {}: {status}",
94            self.name
95        );
96
97        let deadline = Instant::now() + timeout;
98        loop {
99            match child.try_wait() {
100                Ok(Some(_)) => {
101                    self.child.take();
102                    return true;
103                }
104                Ok(None) if Instant::now() < deadline => {
105                    std::thread::sleep(Duration::from_millis(25));
106                }
107                Ok(None) => return false,
108                Err(error) => panic!("failed to wait for {} after SIGTERM: {error}", self.name),
109            }
110        }
111    }
112
113    /// Panics with captured logs when the child has exited unexpectedly.
114    pub fn assert_running(&mut self) {
115        let Some(child) = self.child.as_mut() else {
116            panic!("test process {} has already stopped", self.name);
117        };
118        if let Some(status) = child
119            .try_wait()
120            .unwrap_or_else(|error| panic!("failed to query process {}: {error}", self.name))
121        {
122            panic!(
123                "test process {} exited with {status}\n{}",
124                self.name,
125                self.diagnostics()
126            );
127        }
128    }
129
130    /// Returns the tail of stdout and stderr for failure reporting.
131    pub fn diagnostics(&self) -> String {
132        format!(
133            "--- {} stdout ---\n{}\n--- {} stderr ---\n{}",
134            self.name,
135            read_log_tail(&self.stdout_log),
136            self.name,
137            read_log_tail(&self.stderr_log)
138        )
139    }
140}
141
142impl Drop for TestProcess {
143    fn drop(&mut self) {
144        self.terminate();
145    }
146}
147
148fn assert_valid_process_name(name: &str) {
149    assert!(
150        !name.is_empty()
151            && name
152                .bytes()
153                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'),
154        "test process name must be non-empty ascii alphanumeric or '-': {name:?}"
155    );
156}
157
158fn read_log_tail(path: &Path) -> String {
159    let mut file = match File::open(path) {
160        Ok(file) => file,
161        Err(error) => return format!("<failed to open log: {error}>"),
162    };
163    let mut bytes = Vec::new();
164    if let Err(error) = file.read_to_end(&mut bytes) {
165        return format!("<failed to read log: {error}>");
166    }
167    let start = bytes.len().saturating_sub(16 * 1024);
168    String::from_utf8_lossy(&bytes[start..]).into_owned()
169}
170
171#[cfg(test)]
172mod tests {
173    use super::{TestProcess, available_loopback_port};
174    use std::process::Command;
175    use std::time::Duration;
176
177    #[test]
178    fn available_port_is_nonzero() {
179        assert_ne!(available_loopback_port(), 0);
180    }
181
182    #[cfg(unix)]
183    #[test]
184    fn process_captures_logs_and_terminates_on_request() {
185        let mut command = Command::new("/bin/sh");
186        command.args(["-c", "echo ready; echo warning >&2; sleep 30"]);
187        let mut process = TestProcess::spawn("capture", &mut command);
188
189        process.assert_running();
190        std::thread::sleep(std::time::Duration::from_millis(50));
191        let diagnostics = process.diagnostics();
192        assert!(diagnostics.contains("ready"));
193        assert!(diagnostics.contains("warning"));
194
195        process.terminate();
196    }
197
198    #[test]
199    fn process_rejects_unsafe_fixture_names() {
200        let result = std::panic::catch_unwind(|| {
201            let mut command = Command::new("unused");
202            TestProcess::spawn("../escape", &mut command)
203        });
204        assert!(result.is_err());
205    }
206
207    #[cfg(unix)]
208    #[test]
209    fn process_supports_graceful_termination() {
210        let mut command = Command::new("/bin/sh");
211        command.args(["-c", "trap 'exit 0' TERM; while true; do sleep 1; done"]);
212        let mut process = TestProcess::spawn("graceful", &mut command);
213
214        assert!(process.terminate_gracefully(Duration::from_secs(2)));
215    }
216}