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