aster_forge_test/
process.rs1use 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#[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
26pub 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 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 #[must_use]
74 pub fn name(&self) -> &str {
75 &self.name
76 }
77
78 #[must_use]
80 pub fn runtime_dir(&self) -> &Path {
81 self.runtime_dir.path()
82 }
83
84 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 #[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 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 #[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}